Files
Acelogic f47befb905 probes: split xnuboot.c into ordered parts
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).
2026-10-23 04:04:44 -04:00

1589 lines
80 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""The boot profile and the physical layout, checked without a target.
Four things this covers, docs/PLAN-HISTORY-20260922.md section 1 and Section 0's T4:
- **The guest's memory is a number, not a leftover.** place() used to declare
whatever the free span happened to be, so the size XNU saw changed with the
firmware's mood. It now takes the size it is given and only requires the
free span to be big enough, and the tests below are the difference between
those two behaviours.
- **The layout is checked against itself.** Every offset in xnuboot.py was
chosen separately; an overlap between two of them does not fault, it writes
one structure over part of another and hands XNU a plausible hybrid.
- **A firmware swap is detected.** Every address in the loader was measured
against one set of images, and none of them are re-derived at run time.
- **Stage 2 covers what we own, and nothing else.** The range list the probe
maps is derived here from the numbers it is handed and checked against
every chunk the loader uploads, every table page, and the panel.
No hardware or proxy: identity checks use temporary fixtures; loader
integration checks use the local audited firmware bundle when available.
python3 tools/test-profile.py
"""
import hashlib
import importlib.util
import pathlib
import re
import struct
import sys
import tempfile
from monitor_layout import MonitorLayout
from source_text import read_source
ROOT = pathlib.Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location('xnuboot', ROOT / 'probes' / 'xnuboot.py')
xb = importlib.util.module_from_spec(spec)
spec.loader.exec_module(xb)
checks = 0
MIB = 1 << 20
def ok(name, condition, detail=''):
global checks
checks += 1
if not condition:
print(f'FAIL: {name}' + (f'\n {detail}' if detail else ''))
sys.exit(1)
def raises(name, call, needle=''):
"""The failure has to happen *and* say why: an error nobody can read is
only marginally better than no error."""
global checks
checks += 1
try:
call()
except (ValueError, SystemExit) as error:
if needle and needle not in str(error):
print(f'FAIL: {name}\n raised, but said {str(error)!r}, '
f'which does not mention {needle!r}')
sys.exit(1)
return
print(f'FAIL: {name}\n did not raise')
sys.exit(1)
# A base on the 32 MiB grid. DEFAULT_PHYS_BASE is not one -- it is the raw
# address the A16's firmware reported once, and place() would reject it -- so
# these tests pick their own rather than depending on that.
GRID_BASE = 0x40000000
def restore():
"""Back to the shipped guest size. place() writes module globals."""
xb.place(GRID_BASE, guest_span=xb.GUEST_SPAN)
# --------------------------------------------------------------------------
# The guest's memory is stated
ok('the default guest size is 384 MiB',
xb.DEFAULT_GUEST_MIB == 384 and xb.GUEST_SPAN == 384 * MIB,
f'DEFAULT_GUEST_MIB is {xb.DEFAULT_GUEST_MIB}')
# A free span far larger than the guest must not grow the guest. This is the
# regression that mattered: under QEMU the free region is over 2 GiB, the old
# place() declared all of it, and the frame table -- sized from a fixed arena --
# could not cover it.
xb.place(0x40000000, 2161 * MIB)
ok('a 2161 MiB free span still declares 384 MiB',
xb.REGION_SPAN == 384 * MIB,
f'declared {xb.REGION_SPAN >> 20} MiB')
ok('the reserve sits above the declaration, inside the memory held',
xb.BOOTSTRAP_PA == 0x40000000 + 384 * MIB
and xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN <= 0x40000000 + 2161 * MIB)
# And an explicit size is honoured, rather than being a hint.
xb.place(0x40000000, 2161 * MIB, guest_span=512 * MIB)
ok('an explicit guest size is what gets declared', xb.REGION_SPAN == 512 * MIB,
f'declared {xb.REGION_SPAN >> 20} MiB')
restore()
# Too little memory is a refusal, not a smaller guest.
raises('a free span too small for guest + reserve is refused',
lambda: xb.place(0x40000000, 400 * MIB), 'not enough')
raises('a guest smaller than the image and its tables is refused',
lambda: xb.place(0x40000000, 2161 * MIB, guest_span=64 * MIB),
'not enough')
raises('an unaligned base is refused',
lambda: xb.place(0x40000001), 'grid')
raises('an unaligned guest size is refused',
lambda: xb.place(0x40000000, 2161 * MIB, guest_span=100 * MIB), 'grid')
restore()
# free_region() asks for guest + reserve, and place() then requires the same
# thing, so the two must agree about what "enough" means. A span of exactly
# guest + reserve has to be accepted: an off-by-one here rejects every region
# the runner would pick.
xb.place(0x40000000, 384 * MIB + xb.BOOTSTRAP_SPAN)
ok('exactly guest + reserve is enough', xb.REGION_SPAN == 384 * MIB)
restore()
# --------------------------------------------------------------------------
# The layout is checked against itself
placed = sorted(xb.reservations(), key=lambda r: r[1])
for (a_name, a_off, a_span), (b_name, b_off, _) in zip(placed, placed[1:]):
ok(f'{a_name} does not run into {b_name}', a_off + a_span <= b_off,
f'{a_name} ends at {a_off + a_span:#x}, {b_name} starts at {b_off:#x}')
last_name, last_off, last_span = placed[-1]
ok('the shipped layout fits the declared region',
last_off + last_span <= xb.REGION_SPAN,
f'{last_name} ends at {last_off + last_span:#x} of {xb.REGION_SPAN:#x}')
ok('the six per-CPU result areas fit reserved monitor data',
xb.SCRATCH_COUNT == 6 and xb.SCRATCH_STRIDE == 0x8000 and
xb.SCRATCH_SPAN == 6*0x8000 and
xb.SCRATCH_OFFSET + xb.SCRATCH_SPAN <= xb.SPTM_DATA_SPAN)
# An overlap has to be caught rather than silently written through.
saved = xb.FRAMETABLE_OFFSET
try:
xb.FRAMETABLE_OFFSET = xb.TABLE_OFFSET + 0x1000
raises('an overlapping reservation is caught', xb.validate_layout, 'runs into')
finally:
xb.FRAMETABLE_OFFSET = saved
xb.validate_layout() # and the real one still passes
checks += 1
saved = xb.SPTM_DATA_SPAN
try:
xb.SPTM_DATA_SPAN = 512 * MIB
raises('a reservation past the end of the region is caught',
xb.validate_layout, 'past the')
finally:
xb.SPTM_DATA_SPAN = saved
restore()
# --------------------------------------------------------------------------
# A firmware swap is detected
names = list(xb.PROFILE_FIRMWARE)
ok('the profile names every image the loader reads',
set(names) == {'kernelcache.release.mac17p', 'DeviceTree.j700ap',
'sptm.t8140.release', 'txm.macosx.release'},
f'profile names {names}')
ok('every recorded hash is a sha256',
all(len(h) == 64 and all(c in '0123456789abcdef' for c in h)
for h in xb.PROFILE_FIRMWARE.values()))
with tempfile.TemporaryDirectory() as directory:
fake = pathlib.Path(directory)
bad = xb.check_profile(fake)
ok('an empty firmware directory is rejected', len(bad) == len(names),
f'{len(bad)} complaints for {len(names)} files')
ok('the complaint names the file that is missing',
all(any(name in line for line in bad) for name in names))
# Files present but wrong: the hash has to be what rejects them, not their
# absence, so each one gets content whose digest is certainly not recorded.
for name in names:
(fake / name).write_bytes(name.encode() + b' is not the real image')
bad = xb.check_profile(fake)
ok('wrong contents are rejected', len(bad) == len(names))
ok('the complaint quotes both hashes',
all('sha256' in line and 'profile' in line for line in bad))
# And a directory that does match is accepted, which is the half that
# proves the check is comparing rather than always failing. No file here can
# be made to hash to a chosen value, so the measured digests are handed in
# directly: this half tests the comparison, the block below tests the hash.
measured = {name: xb.PROFILE_FIRMWARE[name] for name in names}
ok('matching hashes are accepted', xb.check_profile(fake, measured) == [])
ok('one wrong file out of four is still one complaint',
len(xb.check_profile(fake, dict(measured, **{names[0]: 'f' * 64}))) == 1)
# The hasher itself, against a file whose digest this test computes the other
# way round.
with tempfile.TemporaryDirectory() as directory:
fake = pathlib.Path(directory)
body = b'x' * ((1 << 22) + 7) # over the read block, to exercise it
(fake / names[0]).write_bytes(body)
got = xb.firmware_hashes(fake)
ok('sha256 is computed over the whole file',
got[names[0]] == hashlib.sha256(body).hexdigest())
ok('a missing file hashes to None', got[names[1]] is None)
# --------------------------------------------------------------------------
# The bitmap count at handoff +0x68, and the underflow a zero there causes
#
# This is the shape of bug that is worth a permanent test: the field is four
# bytes of a structure nobody reads back, a zero in it is indistinguishable
# from "not set yet", and the consequence is a 512 MiB memset half a second
# later and a thousand instructions away.
#
# XNU's arithmetic, at 0xfffffe000ba66404:
# sub w11, w9, #1 ; cmp w11, #0x40 ; b.hs -> bulk
# lsr w10, w11, #6 ; and x12, x10, #0x3fffff8 ; 64 bytes per iteration
def fill_bytes(count):
"""How much the kernel's loop writes for a given count. 32-bit wrapping."""
w11 = (count - 1) & 0xFFFFFFFF
if w11 < 0x40:
return 0
return ((w11 >> 6) & 0x3FFFFF8) // 8 * 64
def bitmap_bytes(count):
"""The buffer XNU sizes for that count: ((n + 0x3f) >> 3) & 0x1ffffff8."""
return ((count + 0x3F) >> 3) & 0x1FFFFFF8
ok('a zero count underflows into a half-gigabyte fill',
fill_bytes(0) > 500 * MIB,
f'a zero count fills {fill_bytes(0)} bytes')
ok('the count is 0x10000, the width the free path\'s clamp permits',
xb.BITMAP_BITS == 0x10000)
ok('0x10000 sizes the bitmap to exactly 8192 bytes',
bitmap_bytes(xb.BITMAP_BITS) == 8192,
f'{bitmap_bytes(xb.BITMAP_BITS)} bytes')
ok('which is one 16K page, so it fits a single granule',
bitmap_bytes(xb.BITMAP_BITS) <= (1 << 14))
ok('and the fill loop becomes 0x3ff iterations',
((xb.BITMAP_BITS - 1) >> 6) == 0x3FF,
f'{(xb.BITMAP_BITS - 1) >> 6:#x}')
ok('the fill covers the whole bitmap and no more',
fill_bytes(xb.BITMAP_BITS) <= bitmap_bytes(xb.BITMAP_BITS))
# The reason the value changed. The free path clamps its byte offset to 0x1ff8
# and stores eight bytes, so it can reach byte 0x2000 of the bitmap whatever the
# count says -- the bound lives in the allocator, not in the buffer. A count
# that sizes the buffer below that leaves a gap the free path is permitted to
# write into.
FREE_PATH_REACH = 0x1FF8 + 8
ok('the buffer covers everything the free path can reach',
bitmap_bytes(xb.BITMAP_BITS) >= FREE_PATH_REACH,
f'{bitmap_bytes(xb.BITMAP_BITS)} bytes, but the free path reaches '
f'{FREE_PATH_REACH}')
ok('0x800 would not have, which is why it was replaced',
bitmap_bytes(0x800) < FREE_PATH_REACH,
f'0x800 sizes {bitmap_bytes(0x800)} bytes')
ok('the count is written as four bytes, not eight',
('bitmap count', xb.HANDOFF_BITMAP_COUNT, 4, 'measured') in xb.handoff_fields(),
'the field table does not carry it as a u32')
ok('the fill stays inside the region we declare',
fill_bytes(xb.BITMAP_BITS) < xb.GUEST_SPAN)
# And the fields really reach the machine. Building takes a few seconds, so it
# is done once and everything is checked against it.
#
# Flattened the way run.py uploads -- sorted by address, later writes winning --
# rather than by reading chunks out of the dict. Those are not the same thing:
# chunks overlap, and the handoff's 0x4000..0x8000 zero region covers the debug
# table at 0x5000, so reading `chunks[HANDOFF_PA + 0x5000]` and reading the
# uploaded image give different answers. The first version of this check read
# the chunk and concluded the table was all zeros; only the flattened view is
# what XNU sees.
import struct as _struct
built = xb.build(str(xb.DEFAULT_KERNELCACHE))
ok('six result buffers are uploaded completely zeroed',
built['chunks'][xb.SCRATCH_PA] == bytes(6 * 0x8000))
ok('result buffers do not overlap other uploaded chunks',
all(pa == xb.SCRATCH_PA or pa + len(blob) <= xb.SCRATCH_PA
or pa >= xb.SCRATCH_PA + xb.SCRATCH_SPAN
for pa, blob in built['chunks'].items()))
for slot in range(6):
for offset in (0, 0x3fff, 0x4000, 0x7fff):
displacement = slot * 0x8000 + offset
ok(f'CPU {slot} result buffer +{offset:#x} maps to its own backing',
built['tables'].walk(built['scratch_va'] + displacement)
== xb.SCRATCH_PA + displacement)
ok('virtual HCR describes the audited kernel return regime',
int.from_bytes(built['chunks'][built['vncr_va']][0x78:0x80], 'little')
== xb.GUEST_PROFILE.kernel_hcr == 0x488000000)
ok('virtual TPIDR_EL2 remains empty until a guest vCPU exists',
built['chunks'][built['vncr_va']][0x90:0x98] == bytes(8))
def flatten(base, span):
"""The bytes at `base` as the machine will hold them after the upload."""
image = bytearray(span)
for pa, blob in sorted(built['chunks'].items()):
if pa >= base + span or pa + len(blob) <= base:
continue
image[pa - base:pa - base + len(blob)] = bytes(blob)
return bytes(image)
page = flatten(xb.HANDOFF_PA, 0x80000)
ok('+0x68 is the count, as a u32',
_struct.unpack_from('<I', page, xb.HANDOFF_BITMAP_COUNT)[0] == xb.BITMAP_BITS)
ok('the randseed tag survives it',
page[xb.RANDSEED_TAG_OFFSET:xb.RANDSEED_TAG_OFFSET + 8] == b'randseed')
ok('the seed length is tag + bytes',
_struct.unpack_from('<Q', page, xb.RANDSEED_LENGTH_OFFSET)[0]
== 8 + xb.RANDSEED_BYTES)
# +0x18 is the first physical address XNU's early allocator hands out
# (handoff +0x18 -> 0xfffffe0007d9fd60 -> the cursor at 0xfffffe0007da06c8;
# pmap_bootstrap carves its pv page, ptd arena and ASID bitmap from it). It has
# to clear every byte the upload places AND every fixed reservation, because the
# monitor region's backing is written as chunks after this word is computed: a
# maximum over the chunks that existed at the time stopped at the table arena
# and published the first page behind 0xfffffecfffcfc000 as free.
avail = _struct.unpack_from('<Q', page, xb.SPTM_AVAIL_START)[0]
top_chunk = max(pa + len(b) for pa, b in built['chunks'].items())
ok('+0x18 is above every uploaded chunk', avail >= top_chunk,
f'+0x18 is {avail:#x}, the upload ends at {top_chunk:#x}')
top_fixed = max(xb.PHYS_BASE + off + span for _, off, span in xb.reservations())
ok('+0x18 is above every fixed reservation', avail >= top_fixed,
f'+0x18 is {avail:#x}, the reservations end at {top_fixed:#x}')
sptm_backing_end = xb.SPTM_DATA_PA + (xb.SPTM_REGION_END - xb.SPTM_REGION_VA)
# Temporary windows must begin vacant while their complete table hierarchy
# exists. A full-region RAM mapping caused the native "out of windows" panic.
copy_base, copy_pages = xb.GUEST_PROFILE.cpu_copy_windows
copy_count = xb.GUEST_PROFILE.monitor_stack_count * copy_pages
for i in range(copy_count):
va = copy_base + i * 0x4000
table = built['tables'].root
for level in range(xb.start_level(), 3):
descriptor = built['tables'].read(table, xb.index(level, va))
assert descriptor & 3 == 3, (i, level, hex(descriptor))
table = descriptor & 0x0000ffffffffc000
leaf = built['tables'].read(table, xb.index(3, va))
ok(f'CPU copy window {i} has allocated tables and a vacant leaf',
leaf == 0 and built['tables'].walk(va) is None)
for va in (copy_base - 0x4000, copy_base + copy_count * 0x4000, xb.SPTM_REGION_END - 0x4000):
ok(f'monitor backing beside copy windows remains at its original PA: {va:#x}',
built['tables'].walk(va) == xb.SPTM_DATA_PA + va - xb.SPTM_REGION_VA)
ok('+0x18 is above the monitor region backing', avail >= sptm_backing_end,
f'+0x18 is {avail:#x}, the backing ends at {sptm_backing_end:#x}')
# The check has to be able to fail: the old formula, a maximum over the chunks
# placed before the word was written, lands inside the backing.
old_formula = max(max(pa + len(b) for pa, b in built['chunks'].items()
if pa < xb.SPTM_DATA_PA),
xb.TABLE_PA + xb.TABLE_SPAN)
ok('...and a maximum over the earlier chunks alone would not be',
old_formula < sptm_backing_end,
f'old formula {old_formula:#x} is not below {sptm_backing_end:#x}, '
'so this check cannot tell the two apart')
# The "12 GiB" MAP_PAGEs, derived instead of observed. The five pages XNU asks
# for above its static map are its serial console's registers, out of the tree
# we hand it: /arm-io/dockchannel-uart `reg` translated through arm-io's
# `ranges`. Measured on the device: four pages at 0x308128000 then one at
# 0x30810c000, in that order, with avail_start untouched. If the tree or the
# translation changes, this is where the number moves first.
windows = built['uart_windows']
pages = [base + n * 0x4000
for base, size in windows for n in range((size + 0x3fff) >> 14)]
ok('the console has two register windows',
[(b, s) for b, s in windows] == [(0x308128000, 0x10000), (0x30810c000, 0x1000)],
f'{[(hex(b), hex(s)) for b, s in windows]}')
ok('...which are the five pages the device measured, in order',
pages == [0x308128000, 0x30812c000, 0x308130000, 0x308134000, 0x30810c000],
f'{[hex(p) for p in pages]}')
ok('...and none of them is memory we declared',
all(not (xb.PHYS_BASE <= p < xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN) for p in pages),
'a console register window inside the memory window would be mapped as RAM')
import types as _types
class _Prop:
def __init__(self, value):
self.value = value
_fake = _types.SimpleNamespace(nodes={
'/device-tree/arm-io': {
'#address-cells': _Prop(b'\x02\0\0\0'), '#size-cells': _Prop(b'\x02\0\0\0'),
'ranges': _Prop(_struct.pack('<QQQ', 0, 0x210000000, 0x2f0000000))},
'/device-tree/arm-io/x': {'reg': _Prop(_struct.pack('<QQ', 0x2f0000000, 0x1000))},
})
raises('a window outside every bus range is refused',
lambda: xb.device_windows(_fake, 'x'), 'outside every')
_fake.nodes['/device-tree/arm-io']['#size-cells'] = _Prop(b'\x01\0\0\0')
raises('a bus without 64-bit cells is refused',
lambda: xb.device_windows(_fake, 'x'), '64-bit')
# The seed size is checked for an EXACT value, not a bound. prng_random.c:75
# does `mov w8, #0x100` and panics "Expected %u seed bytes from bootloader, but
# got %u" -- so 64 panics exactly as 0 would, and so would 512. Asserted as an
# equality for that reason.
ok('the seed is exactly 256 bytes, which prng_random.c:75 requires',
xb.RANDSEED_BYTES == 256, f'{xb.RANDSEED_BYTES} bytes')
ok('so the published length is 0x108',
_struct.unpack_from('<Q', page, xb.RANDSEED_LENGTH_OFFSET)[0] == 0x108)
ok('the seed really is 256 bytes in the image, and not zeros',
len(set(page[xb.RANDSEED_DATA_OFFSET:
xb.RANDSEED_DATA_OFFSET + xb.RANDSEED_BYTES])) > 16,
'the seed looks constant or short')
# The layout only fits at this size: the seed ends immediately before the
# length field, with nothing spare.
ok('the seed ends just below the length field',
xb.RANDSEED_DATA_OFFSET + xb.RANDSEED_BYTES <= xb.RANDSEED_LENGTH_OFFSET,
f'seed ends at {xb.RANDSEED_DATA_OFFSET + xb.RANDSEED_BYTES:#x}, '
f'length field is at {xb.RANDSEED_LENGTH_OFFSET:#x}')
ok('nothing in the handoff overlaps any more', xb.check_handoff_overlap() == [],
f'{xb.check_handoff_overlap()}')
# --------------------------------------------------------------------------
# The debug table at +0x60, replaying XNU's validator at 0xfffffe000b88b0e0
#
# A wrong value here does not fault and does not panic -- it takes a bail path
# that calls the same continuation with different arguments. That is precisely
# the kind of wrong answer that never announces itself, so it gets a test.
debug_va = _struct.unpack_from('<Q', page, xb.HANDOFF_DEBUG_TABLE)[0]
ok('+0x60 is not null, which is the validator\'s first check', debug_va != 0)
debug_pa = built['tables'].walk(debug_va)
ok('+0x60 resolves through the tables XNU will walk', debug_pa is not None,
f'{debug_va:#x} is unmapped')
header = flatten(debug_pa, xb.DEBUG_TABLE_HEADER)
magic, version, count = _struct.unpack_from('<III', header, 0)
ok("the magic is 'DEBG'", magic == xb.DEBUG_TABLE_MAGIC,
f'{magic:#010x} is {magic.to_bytes(4, "little")!r}, not b"DEBG"')
ok('the magic really spells DEBG in memory order',
magic.to_bytes(4, 'little') == b'DEBG')
ok('the version is 2', version == xb.DEBUG_TABLE_VERSION, f'version {version}')
# The header is not the whole structure, and this is the regression that taught
# us so, so the pointers are checked BEFORE anything about the count -- a
# failure here is the one that stops a boot, and it should be the line printed.
#
# A second consumer at 0xfffffe000ba7a6ec reads +0x10 and +0x20 WITHOUT
# consulting the count and walks each as a Mach-O. The walk has no bound, so a
# null faults at 0x20 and a wrong pointer wanders -- neither says what happened.
written = max(xb.DEBUG_TABLE_IMAGES) + 8 # the slots are 16 apart, not adjacent
header_full = flatten(debug_pa, written)
for slot, name in zip(xb.DEBUG_TABLE_IMAGES, xb.MONITOR_IMAGES):
image_va = _struct.unpack_from('<Q', header_full, slot)[0]
ok(f'+{slot:#x} ({name}) is not null', image_va != 0,
'a null here faults at 0x20, which is exactly the regression')
image_pa = built['tables'].walk(image_va)
ok(f'+{slot:#x} resolves through the guest tables', image_pa is not None,
f'{image_va:#x} is unmapped')
macho = flatten(image_pa, 0x4000)
magic_word, _, _, _, ncmds = _struct.unpack_from('<IiiII', macho, 0)
ok(f'+{slot:#x} points at a 64-bit Mach-O header',
magic_word == 0xFEEDFACF, f'magic {magic_word:#x}')
# XNU: x22 = image + 0x20, then advance by cmdsize until cmd == 0x19 and
# strncmp(segname, "__TEXT", 16) matches.
cursor, found, steps = 0x20, None, 0
while steps < ncmds + 4:
cmd, cmdsize = _struct.unpack_from('<II', macho, cursor)
if cmd == 0x19 and macho[cursor + 8:cursor + 14] == b'__TEXT' \
and macho[cursor + 14] == 0:
found = cursor
break
if not cmdsize:
break
cursor += cmdsize
steps += 1
ok(f'+{slot:#x} has a __TEXT segment the walk can find', found is not None,
'the walk advances by cmdsize with no bound, so a miss does not '
'terminate cleanly')
ok(f'+{slot:#x} finds it on the first load command', found == 0x20,
f'found at +{found:#x}; correct but further in than expected')
# Only now the count, which is the weaker claim: a count of 0 leaves the
# validator computing a size of 0x10, which excludes the pointers just checked.
ok('the count is what the loader declares', count == xb.DEBUG_TABLE_COUNT)
validator_size = count * xb.DEBUG_TABLE_ENTRY + xb.DEBUG_TABLE_HEADER
ok('the size the validator computes covers what is actually written',
validator_size >= written,
f'validator says {validator_size:#x}, the table is {written:#x} bytes')
# --------------------------------------------------------------------------
# Monitor stack ranges: both XNU's exception predicate and TXM's unconditional
# six-buffer translation must agree with the uploaded memory and reservations.
region_ptr = _struct.unpack_from('<Q', page, xb.HANDOFF_TXM_STACKS)[0]
region_count = _struct.unpack_from('<I', page, xb.HANDOFF_TXM_STACK_COUNT)[0]
region_low = _struct.unpack_from('<Q', page, xb.HANDOFF_CPU_STACK_LOW)[0]
region_high = _struct.unpack_from('<Q', page, xb.HANDOFF_CPU_STACK_HIGH)[0]
ok('the region array pointer is not null', region_ptr != 0)
ok('the region count is not zero, which the predicate rejects outright',
region_count != 0)
region_pa = built['tables'].walk(region_ptr)
ok('the region array resolves through the guest tables', region_pa is not None,
f'{region_ptr:#x} is unmapped')
array_bytes = flatten(region_pa, region_count * 8)
array = list(_struct.unpack_from(f'<{region_count}Q', array_bytes, 0))
ok('the array is ascending, as the predicate assumes',
all(a < b for a, b in zip(array, array[1:])), f'{[hex(a) for a in array]}')
ok('the count matches the entries actually written', len(array) == region_count)
def accepts(x):
"""XNU's predicate, at 0xfffffe000b8b78f0 and two identical siblings."""
if not (x < region_low) and x < region_high:
return True # the fast window
if region_count == 0:
return False
if x < array[0]:
return False
return x < array[region_count - 1] + 0x4000
ok('TXM has one distinct stack per audited guest CPU',
region_count == xb.GUEST_PROFILE.monitor_stack_count and len(set(array)) == region_count)
for n, va in enumerate(array):
pa = built['tables'].walk(va)
ok(f'TXM stack {n} is mapped, aligned and outside the guest allocator',
pa is not None and pa % 0x4000 == 0 and xb.SPTM_DATA_PA <= pa
and pa + 0x4000 <= built['alloc_low'])
ok(f'TXM stack {n} is uploaded as a complete zeroed page',
pa in built['chunks'] and built['chunks'][pa] == bytes(0x4000))
ok('monitor CPU stacks are mapped and disjoint from the TXM stacks',
region_low >= array[-1] + 0x4000 and region_high - region_low == 0x100000
and built['tables'].walk(region_low) is not None
and built['tables'].walk(region_high - 1) < built['alloc_low'])
for x, want in ((array[0] - 1, False), (array[0], True),
(array[-1] + 0x3fff, True), (region_low, True),
(region_high - 1, True), (region_high, False),
(xb.PHYS_BASE, False), (xb.IMAGE_PA, False), (0, False)):
ok(f'stack predicate {x:#x} is {"accepted" if want else "rejected"}', accepts(x) == want)
# --------------------------------------------------------------------------
# The flat frame map, replaying sptm_get_frame_type (0xfffffe000c22aaec)
#
# Every page we own must resolve to something other than 0x1a, which is the
# kernel's "not in the map" answer and matches no required type. The exact type
# byte is a hypothesis; that every page resolves at all is not.
fm_low = _struct.unpack_from('<Q', page, xb.HANDOFF_TABLE_OFFSET + xb.FRAMEMAP_LOW)[0]
fm_high = _struct.unpack_from('<Q', page, xb.HANDOFF_TABLE_OFFSET + xb.FRAMEMAP_HIGH)[0]
fm_base = _struct.unpack_from('<Q', page, xb.HANDOFF_TABLE_OFFSET + xb.FRAMEMAP_BASE)[0]
type_info = _struct.unpack_from('<Q', page, xb.HANDOFF_TABLE_OFFSET + xb.HANDOFF_FRAME_TYPES)[0]
ok('frame-reference queries receive the audited SPTM type descriptors',
type_info == xb.GUEST_PROFILE.frame_type_info_va)
for frame_type, frame_class in ((11, 3), (20, 2), (25, 3), (35, 3)):
info_pa = built['tables'].walk(type_info + frame_type * 144)
descriptor = next((blob[info_pa - start:info_pa - start + 8]
for start, blob in built['chunks'].items()
if info_pa is not None and start <= info_pa and
info_pa + 8 <= start + len(blob)), b'')
ok(f'type {frame_type} descriptor is backed by the audited monitor data',
len(descriptor) == 8 and descriptor[1] == frame_class)
ok('the frame map bounds are the memory we own',
(fm_low, fm_high) == (xb.PHYS_BASE, xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN),
f'{fm_low:#x}..{fm_high:#x}')
ok('the bounds are physical, as the consumer compares them against a paddr',
fm_low < (1 << 48) and fm_low == xb.PHYS_BASE)
ok('the map base is virtual, as the consumer dereferences it',
fm_base >= xb.KERNEL_BASE, f'{fm_base:#x} is not an upper-half address')
fm_pa = built['tables'].walk(fm_base)
ok('the map base resolves through the guest tables', fm_pa is not None,
f'{fm_base:#x} is unmapped')
ok('it resolves to where the loader put the map', fm_pa == xb.FRAMEMAP_PA,
f'{fm_pa:#x} is not {xb.FRAMEMAP_PA:#x}')
frames = (fm_high - fm_low) >> 14
fm = flatten(xb.FRAMEMAP_PA, frames * xb.FRAMEMAP_ENTRY)
ok('the map is big enough for every frame in range',
len(fm) == frames * xb.FRAMEMAP_ENTRY)
ok('and fits its arena', frames * xb.FRAMEMAP_ENTRY <= xb.FRAMEMAP_SPAN)
def sptm_get_frame_type(paddr):
"""The consumer, replayed: 16 bytes per 16K frame, type byte at +2."""
if not (paddr >= fm_low and paddr < fm_high):
return 0x1A # falls to the region search
index = ((paddr - fm_low) >> 10) & 0x3FFFFFFFFFFFF0
return fm[index + xb.FRAMEMAP_TYPE_BYTE]
want, _ = xb.frame_type()
for label, pa in (('the first page', fm_low),
('the kernel image', xb.IMAGE_PA),
('our page tables', xb.TABLE_PA),
('the last page', fm_high - 0x4000)):
ok(f'{label} resolves to a type', sptm_get_frame_type(pa) == want,
f'{pa:#x} reads {sptm_get_frame_type(pa):#x}, wanted {want:#x}')
ok(f'{label} is not the not-found answer', sptm_get_frame_type(pa) != 0x1A)
ok('one past the end falls out of range', sptm_get_frame_type(fm_high) == 0x1A)
ok('and so does zero', sptm_get_frame_type(0) == 0x1A)
# The scratch must no longer be published at +0x18: that field is the map's low
# bound, and writing a scratch pointer into it is what this commit corrected.
ok('the scratch base is not published over the frame map bound',
fm_low != xb.SCRATCH_PA,
'table +0x18 still holds SCRATCH_PA, which is the bug this fixed')
# --------------------------------------------------------------------------
# The kernel's static virtual map, handoff +0x08 and +0x10
#
# Both were wrong: +0x10 held a physical address and +0x08 was never written,
# leaving XNU carrying the window [0, 0xf2000000]. The test is not that the
# fields are non-zero but that the window covers every kernel VA we map -- a
# window that excludes one is a new silent wrong answer for an old one.
vs_base = _struct.unpack_from('<Q', page, xb.VSPACE_BASE_OFFSET)[0]
vs_top = _struct.unpack_from('<Q', page, xb.VSPACE_TOP_OFFSET)[0]
GUARD = 0xFFFFFED000000000 # 0xfffffe000ba79464: b.hs here and XNU bails
ok('the window base is a kernel VA, not zero and not physical',
vs_base >= xb.KERNEL_BASE, f'{vs_base:#x}')
ok('the window top is a kernel VA', vs_top >= xb.KERNEL_BASE, f'{vs_top:#x}')
ok('the top passes the guard at 0xfffffe000ba79464', vs_top < GUARD,
f'{vs_top:#x} is at or above {GUARD:#x} and XNU would bail')
ok('the early heap is inside the narrow queue-pointer range',
vs_top + 1 < xb.GUEST_PROFILE.queue_pointer_base + (1 << 38))
ok('the top is inclusive, matching the `cset w8, ls` in the consumer',
vs_top == xb.GUEST_PROFILE.early_heap_va - 1)
# The 42-bit encoding at 0xfffffe000ba770d4: (addr - base) must fit.
span = vs_top - vs_base + 1
ok('the span fits the 42-bit offset field', span.bit_length() <= 42,
f'{span:#x} needs {span.bit_length()} bits')
ok('a zero base would NOT have fit, which is why it mattered',
(vs_top - 0 + 1).bit_length() > 42)
# Every kernel VA this loader maps has to be inside it.
built_lo = built['x2']
for label, va in (('the handoff', built_lo),
('the image base', built['vmin']),
('the image top', built['image'].vmax - 1),
('the aperture', xb.APERTURE_VA),
('the aperture top', xb.APERTURE_VA + xb.REGION_SPAN - 1)):
ok(f'{label} is inside the window', vs_base <= va <= vs_top,
f'{va:#x} is outside [{vs_base:#x}, {vs_top:#x}]')
ok('one past the top is outside', not (vs_base <= vs_top + 1 <= vs_top))
# The aperture alone would not have done, which is the reason the window is
# this wide rather than sized to what we publish.
ok('an aperture-sized window would have excluded the monitor region',
not (xb.APERTURE_VA <= xb.SPTM_REGION_VA <= xb.APERTURE_VA + xb.REGION_SPAN))
ok('...and the kernel image', not (xb.APERTURE_VA <= built['vmin']))
# --------------------------------------------------------------------------
# The free-VA walk above the static map, replaying 0xfffffe000ba7a878
#
# XNU walks 24 MiB above VSPACE_TOP expecting the table levels to exist, reads
# the L1 and L2 descriptors, and registers the L3 table page. The L1 entry was
# zero, phystokv(0) failed correctly, and a `csel` substituted zero unchecked.
# Leaves are NOT read, which is the difference between two table pages and the
# whole range's worth of backing.
walk_base = xb.VSPACE_TOP + 1
ok('the walk starts one past the top of the static map',
walk_base == xb.VSPACE_TOP + 1 and walk_base % (1 << 14) == 0)
# The old heap used L1[2029]; the corrected heap shares the image/aperture L1
# and has its own L2 slot. The walk must still find a complete empty L3 table.
walk_off = walk_base & ((1 << (64 - xb.T1SZ)) - 1)
ok('the heap and image share their L1 region',
xb.index(1, walk_off) == xb.index(1, built['vmin']),
f'L1[{xb.index(1, walk_off)}]')
ok('the heap begins at an L3 table boundary', walk_base % (1 << 25) == 0)
ok('one L3 table covers the whole 24 MiB range',
xb.VSPACE_WALK_SPAN <= (1 << (14 + 11)),
f'{xb.VSPACE_WALK_SPAN >> 20} MiB needs more than one L3 table')
tbl = built['tables']
leaf_reach = 1 << (14 + 11) # one L3 table covers 32 MiB
for step in range(0, xb.VSPACE_WALK_SPAN, leaf_reach):
off = (walk_base + step) & ((1 << (64 - xb.T1SZ)) - 1)
table, seen = tbl.root, []
for level in range(xb.start_level(), 3):
entry = tbl.read(table, xb.index(level, off))
seen.append((level, entry))
ok(f'L{level} for {walk_base + step:#x} is present', entry & 1,
f'entry {entry:#x} -- phystokv(0) then faults at FAR_EL1 = 0')
ok(f'L{level} for {walk_base + step:#x} is a table, not a block',
entry & 2)
table = entry & 0x0000FFFFFFFFC000
# Empty AT BUILD TIME, which is what this suite can see: it runs against
# the loader's output and never against the device. XNU populates these as
# soon as it boots -- five of them in the last run -- so an assertion that
# meant "empty on the machine" would be wrong the moment it worked.
ok(f'the L3 table for {walk_base + step:#x} is empty as built',
all(tbl.read(table, n) == 0 for n in range(0, 2048, 256)),
'the loader mapped a leaf here; XNU expects to fill these itself')
# Cost: the whole point of reserving levels rather than mapping the range.
mapped_cost = xb.VSPACE_WALK_SPAN
ok('reserving costs table pages, not the range',
tbl.size < mapped_cost // 8,
f'{tbl.size} bytes of tables against {mapped_cost} if it were mapped')
ok('the tables still fit their arena', tbl.next - tbl.base <= xb.TABLE_SPAN)
# --------------------------------------------------------------------------
# The 42-bit offset encoding at 0xfffffe000ba770d4
#
# `sub x11, x9, x10 ; bfi x10, x11, #0x16, #0x2a` encodes (addr - base) in 42
# bits. Two ways out, failing through different instructions and both silent,
# so they are two checks with two messages rather than one range test.
class FakeTables:
def __init__(self, ranges):
self.ranges = ranges
def refuses(name, ranges, needle):
global checks
checks += 1
try:
xb.validate_vspace(FakeTables(ranges))
except SystemExit as error:
if needle not in str(error):
print(f'FAIL: {name}\n refused, but said {str(error)[:120]!r}')
sys.exit(1)
return
print(f'FAIL: {name}\n was accepted')
sys.exit(1)
refuses('a VA below the base is refused, naming the wrap',
[(xb.VSPACE_BASE - (1 << 14), 1 << 14, 'mapped')], 'would wrap')
refuses('a VA past the 42-bit field is refused, naming the truncation',
[(xb.VSPACE_BASE + xb.VSPACE_OFFSET_LIMIT - (1 << 14), 1 << 15, 'mapped')],
'truncated')
# The two messages must differ: same silent outcome, different arithmetic.
def message(ranges):
try:
xb.validate_vspace(FakeTables(ranges))
except SystemExit as error:
return str(error)
return ''
below = message([(xb.VSPACE_BASE - (1 << 14), 1 << 14, 'mapped')])
past = message([(xb.VSPACE_BASE + xb.VSPACE_OFFSET_LIMIT - (1 << 14), 1 << 15, 'm')])
ok('the two failures report differently', below != past)
ok('neither message mentions the other cause',
'truncated' not in below and 'wrap' not in past)
# The identity reserve is low-half and must be skipped, not failed.
xb.validate_vspace(FakeTables([(0xf2000000, 1 << 14, 'mapped')]))
checks += 1
# The base bound is derived from the top, so it cannot go stale if the top
# moves. Checked by moving the top and seeing the bound move with it.
saved = xb.VSPACE_BASE
try:
xb.VSPACE_BASE = xb.VSPACE_TOP - xb.VSPACE_OFFSET_LIMIT
refuses('a window wider than the field is refused',
[], 'spans more than')
finally:
xb.VSPACE_BASE = saved
xb.validate_vspace(FakeTables([])) # and the real one still passes
checks += 1
# Every range the loader actually maps must satisfy it -- this is the check
# that would catch a new mapping placed outside the encodable window.
xb.validate_vspace(built['tables'])
checks += 1
ok('the loader maps something in the upper half at all',
any(va >= xb.KERNEL_BASE for va, _, _ in built['tables'].ranges))
ok('and records the identity reserve as low-half',
any(va < xb.KERNEL_BASE for va, _, _ in built['tables'].ranges))
# --------------------------------------------------------------------------
# The panel handover -- the first/last panel pages are typed, the gap is not,
# and the fast window still describes only DRAM + reserve.
fb_saved = xb.FRAMEBUFFER
try:
# The device's own layout, not the module defaults: the runner calls
# place(0xda000000, ...), which gives a different root and a lower reserve.
xb.place(0xDA000000, guest_span=xb.GUEST_SPAN)
xb.FRAMEBUFFER = (0xF9A20000, 2732, 1896, 2732 * 4, 32)
fb_base, _, fb_height, fb_stride, _ = xb.FRAMEBUFFER
fb_span = (fb_stride * fb_height + 0x3FFF) & ~0x3FFF
fb_pa = fb_base & ~0x3FFF
# The geometry has to be the panel the device actually reported, or this
# tests an address the machine never asks about. docs/APERTURE-FAULT.md
# recorded the range read back off the target.
ok('the panel geometry reproduces the range recorded on the device',
(fb_pa, fb_pa + fb_span) == (0xF9A20000, 0xFADE4000),
f'derived {fb_pa:#x}..{fb_pa + fb_span:#x}, recorded 0xf9a20000..0xfade4000')
fb_built = xb.build(str(xb.DEFAULT_KERNELCACHE))
def fb_flatten(base, span):
image = bytearray(span)
for pa, blob in sorted(fb_built['chunks'].items()):
if pa >= base + span or pa + len(blob) <= base:
continue
image[pa - base:pa - base + len(blob)] = bytes(blob)
return bytes(image)
fb_page = fb_flatten(xb.HANDOFF_PA, 0x80000)
w_low = _struct.unpack_from('<Q', fb_page, xb.HANDOFF_CPU_STACK_LOW)[0]
w_high = _struct.unpack_from('<Q', fb_page, xb.HANDOFF_CPU_STACK_HIGH)[0]
m_low = _struct.unpack_from('<Q', fb_page,
xb.HANDOFF_TABLE_OFFSET + xb.FRAMEMAP_LOW)[0]
m_high = _struct.unpack_from('<Q', fb_page,
xb.HANDOFF_TABLE_OFFSET + xb.FRAMEMAP_HIGH)[0]
ok('the frame map covers the complete panel',
m_low <= fb_pa and fb_pa + fb_span <= m_high,
f'panel {fb_pa:#x}, map {m_low:#x}..{m_high:#x}')
m_frames = (m_high - m_low) >> 14
m_bytes = fb_flatten(xb.FRAMEMAP_PA, m_frames * xb.FRAMEMAP_ENTRY)
def fb_frame_type(paddr):
if not (paddr >= m_low and paddr < m_high):
return 0x1A
index = ((paddr - m_low) >> 10) & 0x3FFFFFFFFFFFF0
return m_bytes[index + xb.FRAMEMAP_TYPE_BYTE]
# Both ends of the panel, and the hole before it.
ok('a framebuffer page has the requested frame type',
fb_frame_type(fb_pa) == xb.frame_type()[0],
f'reads {fb_frame_type(fb_pa):#x}')
ok('and so does its last page',
fb_frame_type(fb_pa + fb_span - 0x4000) == xb.frame_type()[0])
ok('the gap is described as untyped, not usable RAM',
all(fb_frame_type(pa) == 0 for pa in range(xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN, fb_pa, 0x4000)))
ok('the first byte after the panel remains outside the map',
fb_frame_type(fb_pa + fb_span) == 0x1A)
ok('while a page we do own still resolves to a real type',
fb_frame_type(xb.IMAGE_PA) != 0x1A)
# The panel must never become a monitor stack.
ok('the panel is outside the monitor CPU-stack window',
not (fb_pa >= w_low and fb_pa < w_high),
f'panel {fb_pa:#x}, window {w_low:#x}..{w_high:#x}')
fb_count = _struct.unpack_from('<I', fb_page, xb.HANDOFF_TXM_STACK_COUNT)[0]
fb_ptr = _struct.unpack_from('<Q', fb_page, xb.HANDOFF_TXM_STACKS)[0]
fb_arr_pa = fb_built['tables'].walk(fb_ptr)
fb_arr = list(_struct.unpack_from(
f'<{fb_count}Q', fb_flatten(fb_arr_pa, fb_count * 8), 0))
ok('the panel does not change the monitor stack array',
fb_count == xb.GUEST_PROFILE.monitor_stack_count
and all(fb_built['tables'].walk(va) < xb.SPTM_DATA_PA + xb.SPTM_DATA_SPAN
for va in fb_arr)
and all(not (va <= fb_pa < va + 0x4000) for va in fb_arr))
finally:
xb.FRAMEBUFFER = fb_saved
restore()
# --------------------------------------------------------------------------
# The stage-2 map -- docs/PLAN-HISTORY-20260922.md Section 0, T4: exactly what we own, nothing else
#
# probes/xnuboot.c maps three ranges to themselves at stage 2 -- the memory we
# declared with the reserve above it, the panel when it is handed over, and the
# probe's own work area read-only -- and leaves every other intermediate
# address absent, so a touch stops the run with the IPA. The list is derived by
# xnuboot.stage2_ranges() from the same numbers the probe reads (mem_low,
# mem_high, fb_pa, fb_span in the params blob; the work area from q1n1's heap)
# and is checked here against what the loader actually uploads. The map
# builder itself is walked page by page in tools/test-stage2.c; this is the
# other half, that the list it is handed is the right list.
#
# On the device's own layout, place(0xda000000, ...): the module defaults give
# a different root and a reserve 0x200000 lower, and a check against them is a
# check against a machine that does not exist.
# Historical low-memory work-area fixture, passed as an explicit (base, size).
# Current xnuboot reserves an external arena; test-monitor-work.py and the
# native test-stage2.c cover that placement and its protection boundaries.
S2_WORK = ((0x82000000 + 0x500000 + 0xFFFF) & ~0xFFFF, 0x2000000)
s2_fb_saved = xb.FRAMEBUFFER
try:
xb.place(0xDA000000, guest_span=xb.GUEST_SPAN)
xb.FRAMEBUFFER = None
s2_built = xb.build(str(xb.DEFAULT_KERNELCACHE))
s2_ranges = xb.stage2_ranges(work=S2_WORK)
ok('with the panel withheld the map is two ranges: memory and table arena',
[(w, a) for _, _, a, w in s2_ranges]
== [('DRAM + reserve', 'rw'), ('shadow table arena; private context excluded', 'ro')],
f'{s2_ranges}')
dram_lo, dram_hi = s2_ranges[0][0], s2_ranges[0][1]
ok('the memory range is 0xda000000..0xf5000000 on the device: 384 MiB declared '
'plus the 48 MiB reserve',
(dram_lo, dram_hi) == (0xDA000000, 0xF5000000),
f'{dram_lo:#x}..{dram_hi:#x}')
ok('it starts exactly where the loader starts uploading',
dram_lo == min(s2_built['chunks']) == xb.PHYS_BASE)
ok('and ends exactly at the top of the reserve, not a page further',
dram_hi == xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN == xb.REGION_END + xb.BOOTSTRAP_SPAN)
# Every uploaded chunk, the whole of it, inside the memory range.
s2_outside = [(pa, len(b)) for pa, b in s2_built['chunks'].items()
if not (dram_lo <= pa and pa + len(b) <= dram_hi)]
ok('every uploaded chunk lies inside the memory range', not s2_outside,
f'outside: {[(hex(pa), n) for pa, n in s2_outside[:3]]}')
# Every 16K guest table page too. They are in tables.pages, not in chunks:
# a walk over chunks reads zeros and looks like a missing table.
s2_pages = s2_built['tables'].pages
s2_outside = [pa for pa in s2_pages
if not (dram_lo <= pa and pa + len(s2_pages[pa]) <= dram_hi)]
ok('every 16K guest table page lies inside the memory range', not s2_outside,
f'outside: {[hex(pa) for pa in s2_outside[:3]]}')
ok('there are chunks and table pages to cover, so those checks are not vacuous',
len(s2_built['chunks']) > 10 and len(s2_pages) > 10,
f'{len(s2_built["chunks"])} chunks, {len(s2_pages)} table pages')
ok('the reserve the kernel builds into is inside the range',
dram_lo <= xb.BOOTSTRAP_PA and xb.BOOTSTRAP_PA + xb.BOOTSTRAP_SPAN <= dram_hi)
ok('the work area is exactly what run.py hands xnuboot_setup, read-only',
s2_ranges[1][:3] == (S2_WORK[0], S2_WORK[0] + S2_WORK[1], 'ro'),
f'{s2_ranges[1]}')
# Every page the probe guards -- the monitor-call pages, XNU's vectors, the
# watched handoff pages -- has to be in the map already: a guard changes
# what a mapped page does, and the probe refuses (-4, -5, -7) to guard a
# page that is absent. Checked here so that refusal cannot be a layout's.
s2_guarded = (list(s2_built['stub_pa']) + [s2_built['vbar_pa']]
+ [xb.HANDOFF_PA + n * 0x1000 for n in range(s2_built['watch_count'])])
ok('every page the probe guards is inside the memory range',
s2_guarded and all(dram_lo <= pa < dram_hi for pa in s2_guarded),
f'{[hex(pa) for pa in s2_guarded]}')
# The params blob carries the same two numbers the probe maps from, and no
# panel; and both files agree on its length, read out of the C source.
s2_params = s2_built['chunks'][xb.PARAMS_PA]
ok('the params blob is PARAMS_WORDS long', len(s2_params) == xb.PARAMS_WORDS * 8)
s2_words = _struct.unpack(f'<{xb.PARAMS_WORDS}Q', s2_params)
param_layout=MonitorLayout()
param_word=lambda field:s2_words[param_layout.offset('params',field)//8]
ok('physical and virtual clock rates are distinct appended ABI fields',
(param_word('clock_host_hz'),param_word('clock_guest_hz')) == (xb.HOST_TIMEBASE_HZ, xb.TIMEBASE_HZ) and
xb.TIMEBASE_HZ == xb.GUEST_PROFILE.virtual_counter_hz)
ok('const bounds exclude writable data and disposable LINKEDIT',
(param_word('const_begin'),param_word('const_end'))==(xb.IMAGE_PA,xb.IMAGE_PA+0x5268000))
fm_table = xb.HANDOFF_TABLE_OFFSET
fm_handoff = s2_built['chunks'][xb.HANDOFF_PA]
ok('RETYPE receives the guest-visible frame-map envelope',
s2_words[43:46] == (xb.FRAMEMAP_PA,
_struct.unpack_from('<Q', fm_handoff, fm_table + xb.FRAMEMAP_LOW)[0],
_struct.unpack_from('<Q', fm_handoff, fm_table + xb.FRAMEMAP_HIGH)[0]))
ok('RETYPE excludes loader reservations and the bootstrap reserve',
s2_words[46:48] == (
_struct.unpack_from('<Q', fm_handoff, xb.SPTM_AVAIL_START)[0], xb.REGION_END))
ok('MAP_PAGE receives the guest RAM aperture for its PTE pointer result',
s2_words[48] == xb.APERTURE_VA)
ok('write watch is disabled by default', s2_words[49] == 0)
ok('TXM receives only its dedicated stack and log allocations',
s2_words[50:53] == (s2_built['txm_stacks_pa'],
xb.GUEST_PROFILE.monitor_stack_count, s2_built['txm_log_pa']))
ok('the TXM log starts empty and remains outside the allocator',
s2_built['chunks'][s2_built['txm_log_pa']] == bytes(0x4000)
and s2_built['txm_log_pa'] + 0x4000 <= s2_built['alloc_low'])
info = s2_built['chunks'][s2_built['txm_ro_pa']]
ok('virtual TXM publishes platform class0 with no SEP channel descriptor',
info[0xa] == 0 and _struct.unpack_from('<3Q', info, 0x38) == (0, 0, 0))
policy_va = _struct.unpack_from('<Q', info, xb.GUEST_PROFILE.txm_policy_pointer_offset)[0]
ok('TXM policy pointer resolves to the actual zero-feature policy page',
s2_built['tables'].walk(policy_va) == s2_built['txm_ro_pa'] and info[0x48] == 0)
ok('monitor signing is explicitly unavailable and developer mode is separate',
info[xb.GUEST_PROFILE.txm_csm_disabled_offset] == 1 and info[0x3ff0] == 1)
ok('TXM mutable metrics and policy occupy separate reserved pages',
s2_words[53:55] == (s2_built['txm_rw_pa'], s2_built['txm_ro_pa'])
and s2_words[53] != s2_words[54] and s2_words[54] + 0x4000 <= s2_built['alloc_low'])
pool_pa = s2_built['txm_aspace_pa']
ok('TXM address-space pool is zeroed, mapped and excluded from the allocator',
s2_words[55] == pool_pa and pool_pa >= s2_words[54] + 0x4000
and pool_pa + 0x4000 <= s2_built['alloc_low']
and s2_built['chunks'][pool_pa] == bytes(0x4000)
and s2_built['tables'].walk(xb.APERTURE_VA + pool_pa - xb.PHYS_BASE) == pool_pa)
uat_pa = s2_built['uat_pa']
uat_va = xb.APERTURE_VA + uat_pa - xb.PHYS_BASE
state = s2_built['chunks'][uat_pa]
uat_l1 = s2_built['chunks'][uat_pa + 0x4000]
ok('UAT geometry is derived from this guest DT and uploaded intact',
s2_words[56:60] == (uat_pa, 1, 43, 64))
slide = s2_words[60:90]
ok('virtual device registry follows the unchanged monitor parameter fields',
s2_words[90:99] == (120, 1, 0x220000000, 0x140000, 4, 0, 0, 0, 0)
and s2_words[99:131] == tuple(v for bank in range(4) for v in
(2, 0x220400000 + bank*0x200000, 0x39000, 0, 0, 0, 0, 0))
and s2_words[131:155] == tuple(v for i,n in enumerate((31,36,35)) for v in
(4, 0x24f008000+i*0x1000000, 0x8000, n, 0, 0, 0, 0))
and s2_words[155:163] == (3, 0x301000000, 0x1cc000, 1280, 4096, 0x40000, 0xa, 0)
and s2_words[163:171] == (5, 0x3082b0000, 0x4000, 24000000, 0, 0, 0, 0)
and s2_words[171:179] == (25, 0x3003c0000, 0x14000, 0x10000, 0x1a8, 0x1b8, 8, 0xffb80)
and s2_words[179:187] == (40, 0x31a000000, 0x100000, 216, 7, 0x7f,
5096408961622, 5878381359781)
and s2_words[187:195] == (7, 0x314838000, 0x4000, 10, 7, 0x70, 0, 0)
and s2_words[195:203] == (7, 0x310824000, 0x4000, 57, 7, 0x70, 0, 0)
and s2_words[203:211] == (7, 0x3081f0000, 0x4000, 30, 7, 0x70, 0, 0)
and s2_words[211:219] == (7, 0x30c820000, 0x4000, 6, 7, 0x60, 0, 0)
and s2_words[219:227] == (8, 0x3082dc000, 0x8000, 0, 0x40, 0x44, 0x48, 0x30) # + PMGR BPR word
and s2_words[227:235] == (47, 0x38500c000, 0x4000, 5, 42, 1107, 21, 1)
and s2_words[235:243] == (9, 0x308700000, 0x18000, 0x14000, 0x4000, 487, 256, 256)
and s2_words[243:251] == (9, 0x308600000, 0x18000, 0x14000, 0x4000, 492, 256, 256)
and s2_words[251:259] == (9, 0x310900000, 0x18000, 0x14000, 0x4000, 432, 256, 256)
and s2_words[259:267] == (9, 0x308900000, 0xc000, 0x8000, 0x4000, 494, 256, 256)
and s2_words[267:275] == (10, 0x3082d8000, 0x1000, 8, 1 << 22, 0, 0, 0)
and s2_words[275:283] == (13, 0x314b00000, 0x35000, 0x14000, 0x28000, 1137, 2048, 0)
and s2_words[283:331] == tuple(v for i,base in enumerate(
(0x24f000000,0x250000000,0x251000000,0x252000000,0x25f000000,0x26f000000))
for v in (14,base,0x4000,i,0,0,0,0))
and s2_words[331:339] == (15,0x25300a000,0x1000,0x104,1,0,0,0)
and s2_words[339:347] == (15,0x254000000,0x20000,0x4084,0,0,0,0)
and s2_words[347:355] == (16,0x3082a8000,0x1000,24000000,0,0,0,0))
dart_words = [s2_words[i:i+8] for i in range(355,587,8)]
ok('29 distinct DART banks occupy the expanded registry',
len(dart_words)==29 and all(d[0]==17 for d in dart_words) and
len({d[1] for d in dart_words})==29 and
(17,0x385060000,0x20000,16,300,1120,36,1<<21) in dart_words and
s2_words[587:603]==(18,0x300700000,0x11000,0,1,0,0,0,
18,0x308280000,0x9000,1,0,0,0,0) and
s2_words[603:627]==(19,0x3082c8000,0x4000,0,0,0,0,0,
20,0x210e78000,0x1000,0,0,0,0,0,
20,0x211e78000,0x1000,1,0,0,0,0) and
s2_words[627:643]==(21,0x210e70000,0x1000,0,0,0,0,0,
21,0x211e70000,0x1000,1,0,0,0,0) and
s2_words[643:659]==(22,0x30827b000,0x1000,6,0,0,0,0,
22,0x3082b4000,0x1000,7,0,0,0,0) and
s2_words[659:667]==(23,0x308004000,0x1000,0,0,0,0,0) and
s2_words[667:675]==(24,0x3003d8000,0x1000,0,0,0,0,0) and
s2_words[675:683]==(26,0x300780000,0x1000,1,0,0,0,0) and
s2_words[683:691]==(27,0x300724000,0x1000,0,0,0,0,0) and
s2_words[691:699]==(28,0x30020a000,0x3000,0,0,0,0,0) and
s2_words[699:779]==tuple(word for base in (
0x408000000,0x413000000,0x417000000,0x38b000000,0x380000000,
0x284000000,0x288000000,0x481000000,0x28c000000,0x396000000)
for word in (29,base,0x1000,0,0,0,0,0)) and
s2_words[779:787]==(30,0x3028ac000,0x1000,0,0,0,0,0) and
s2_words[787:795]==(31,0x302860000,0x1000,0,0,0,0,0) and
s2_words[795:803]==(32,0x3028a4000,0x6000,0,0,0,0,0) and
s2_words[803:811]==(33,0x300760000,0x1000,0,0,0,0,0) and
s2_words[811:859]==tuple(word for group,base,span in (
(0,0x30006d000,0x2000),(1,0x300720000,0x3000),
(2,0x300778000,0x1000),(3,0x30077c000,0x1000),
(4,0x3007a0000,0x1000),(5,0x302900000,0x1000))
for word in (34,base,span,group,0,0,0,0)) and
s2_words[859:875]==(35,0x210e20000,0x2000,0,0,0,0,0,
35,0x211e20000,0x2000,1,0,0,0,0) and
s2_words[875:939]==tuple(word for base in (
0x210059000,0x210159000,0x210259000,0x210359000,
0x211059000,0x211159000,0x210e48000,0x211e48000)
for word in (36,base,0x1000,0,0,0,0,0)) and
s2_words[939:955]==(37,0x210e44000,0x1000,0,0,0,0,0,
37,0x211e44000,0x1000,1,0,0,0,0) and
s2_words[955:963]==(38,0x300740000,0x1000,0,0,0,0,0) and
s2_words[963:1011]==tuple(word for cpu,base in enumerate((
0x210010000,0x210110000,0x210210000,0x210310000,0x211010000,0x211110000))
for word in (39,base,0x1000,cpu,0,0,0,0)) and
s2_words[1011:1019]==(41,0x385110000,0x5000,0,0,0,0,0) and
s2_words[1019:1027]==(42,0x300200000,0x4000,0,0,0,0,0) and
s2_words[1027:1035]==(43,0x385018000,0x4000,2,6,1110,0,0) and
s2_words[1035:1043]==(44,0x3082b8000,0x1000,8,0,0,0,0) and
s2_words[1043:1051]==(45,0x282600000,0x9000,8,0,0,0,0) and
s2_words[1051:1115]==(0,)*64)
ok('expanded parameters fit between VNCR and the tree',
xb.PARAMS_PA==xb.HANDOFF_PA+0xc000 and
xb.PARAMS_PA+len(s2_params)<=xb.DEVICETREE_PA)
model_dt = xb.DeviceTree(s2_built['chunks'][xb.DEVICETREE_PA])
ok('serialized AIC EVENT property matches the actual model descriptor',
model_dt.get('/device-tree/arm-io/aic', 'aic-iack-offset') ==
_struct.pack('<Q', s2_words[160]))
ok('SLIDE_REGION owns only bootstrap tables and its published region map',
slide[:3] == (xb.TABLE_PA, xb.TABLE_PA + xb.TABLE_SPAN,
xb.HANDOFF_PA + xb.REGIONS_OFFSET))
ok('SLIDE_REGION reserves 64 MiB directly below the early heap',
slide[3:6] == (xb.GUEST_PROFILE.early_heap_va - 0x4000000,
xb.GUEST_PROFILE.early_heap_va, 4))
ok('SLIDE_REGION boot segment extents match the measured guest image',
[(slide[i], slide[i+2]) for i in range(6, 18, 3)] == [
(0xfffffe000c6ac000, 3), (0xfffffe000c83c000, 19),
(0xfffffe000ca78000, 1592), (0xfffffe000c26c000, 243)])
ok('SLIDE_REGION physical addresses follow the actual image placement',
all(slide[i+1] == s2_built['image_pa'] + slide[i] - s2_built['image'].vmin
for i in range(6, 18, 3)) and not any(slide[18:]))
ok('SLIDE_REGION reserved destination leaves remain absent',
all(s2_built['tables'].walk(va) is None
for va in range(slide[3], slide[4], 0x4000)))
from dataclasses import replace
saved_profile = xb.GUEST_PROFILE
for specs in ((), (('fileset', '__MISSING'),),
(('com.apple.missing', '__KLDDATA'),),
(('fileset', '__TEXT_EXEC'),),
saved_profile.slide_segments * 2):
xb.GUEST_PROFILE = replace(saved_profile, slide_segments=specs)
try:
xb.slide_contract(s2_built['image'], s2_built['tables'], s2_built['alloc_low'])
except ValueError:
refused_slide = True
else:
refused_slide = False
finally:
xb.GUEST_PROFILE = saved_profile
ok('SLIDE_REGION refuses missing, empty, executable and overlapping profiles', refused_slide)
ok('UAT private state has its audited type, table pointer and geometry',
state[0] == 8 and _struct.unpack_from('<QHB', state, 0x10) == (uat_va+0x4000,64,2))
ok('UAT reserves separate mapped pages outside all allocator and TXM objects',
uat_pa >= pool_pa + 0x4000 and uat_pa+0xc000 <= s2_built['alloc_low']
and all(s2_built['tables'].walk(uat_va+offset) == uat_pa+offset
for offset in (0,0x4000,0x8000)))
ok('UAT firmware L2 is empty and linked only from its private L1 slot',
_struct.unpack_from('<Q',uat_l1,0x10)[0] == (uat_pa+0x8000)|3
and uat_l1[:0x10] + uat_l1[0x18:] == bytes(0x4000-8)
and s2_built['chunks'][uat_pa+0x8000] == bytes(0x4000))
uat_dt = xb.DeviceTree(pathlib.Path(xb.DEFAULT_DEVICETREE).read_bytes())
mode_prop = uat_dt.nodes[xb.GUEST_PROFILE.uat_node]['agx-address-space-mgmt-mode']
for value in (b'\0'*4, b'\1', b'\2\0\0\0'):
mode_prop.value = bytearray(value)
try:
xb.private_uat_geometry(uat_dt)
except ValueError:
rejected_uat = True
else:
rejected_uat = False
ok('UAT rejects unsupported or malformed modes',rejected_uat)
watched = dict(s2_built, write_watch_va=s2_built['entry'], watchdog=10)
watched_words = _struct.unpack(f'<{xb.PARAMS_WORDS}Q', xb.params_blob(watched))
ok('write watch follows the guest mapping and keeps runtime overrides',
watched_words[49] == (s2_built['tables'].walk(s2_built['entry']) & ~0xfff)
and watched_words[24] == 10)
try:
xb.params_blob(dict(s2_built, write_watch_va=1))
except ValueError:
invalid_watch_rejected = True
else:
invalid_watch_rejected = False
ok('write watch rejects an unmapped address before upload', invalid_watch_rejected)
ok('mem_low/mem_high in the params are the memory range the probe maps',
(s2_words[30], s2_words[31]) == (dram_lo, dram_hi),
f'{s2_words[30]:#x}..{s2_words[31]:#x}')
ok('fb_pa/fb_span in the params are zero with the panel withheld',
(s2_words[32], s2_words[33]) == (0, 0))
s2_c = read_source(ROOT / 'probes' / 'xnuboot.c')
s2_m = re.search(r'#define PARAMS_WORDS (\d+)', s2_c)
ok('PARAMS_WORDS agrees with probes/xnuboot.c',
s2_m is not None and int(s2_m.group(1)) == xb.PARAMS_WORDS,
f'C says {s2_m.group(1) if s2_m else None}, loader says {xb.PARAMS_WORDS}')
ok('struct params in probes/xnuboot.c ends with fb_pa, fb_span',
re.search(r'uint64_t mem_low, mem_high;.*?uint64_t fb_pa, fb_span;.*?\n\};',
s2_c, re.S) is not None)
# What is NOT in the map, by name. A touch of any of these stops the run.
for addr, what in ((0, 'address zero'),
(0xD9FFF000, 'the page below DRAM'),
(0xF5000000, 'the page above the reserve'),
(0xF9A20000, 'the panel, while withheld'),
(1 << 35, 'an address high in the 36-bit space')):
ok(f'{what} is in no range', xb.stage2_where(addr, s2_ranges) is None)
ok('the first and last pages of the memory range are in it',
xb.stage2_where(dram_lo, s2_ranges) is not None
and xb.stage2_where(dram_hi - 0x1000, s2_ranges) is not None)
# With the panel handed over it is the third range, read-write, exactly the
# panel's own pages -- the range recorded on the device -- and the params
# carry it, and it is the same range the aperture publishes.
xb.FRAMEBUFFER = (0xF9A20000, 2732, 1896, 2732 * 4, 32)
s2_fb_built = xb.build(str(xb.DEFAULT_KERNELCACHE))
s2_ranges = xb.stage2_ranges(work=S2_WORK)
ok('with the panel handed over the map is three ranges',
[w for _, _, _, w in s2_ranges]
== ['DRAM + reserve', 'panel', 'shadow table arena; private context excluded'])
s2_panel = s2_ranges[1]
ok('the panel range is the one recorded on the device, read-write',
s2_panel[:3] == (0xF9A20000, 0xFADE4000, 'rw'), f'{s2_panel}')
s2_words = _struct.unpack(f'<{xb.PARAMS_WORDS}Q', s2_fb_built['chunks'][xb.PARAMS_PA])
ok('fb_pa/fb_span in the params are the panel range',
(s2_words[32], s2_words[33]) == (s2_panel[0], s2_panel[1] - s2_panel[0]),
f'{s2_words[32]:#x} +{s2_words[33]:#x}')
s2_ap = s2_fb_built['chunks'][xb.HANDOFF_PA + xb.REGIONS_OFFSET]
s2_n = _struct.unpack_from('<I', s2_ap, 0)[0]
s2_regions = [_struct.unpack_from('<QQI', s2_ap, 0x10 + n * xb.REGION_ENTRY_SIZE)
for n in range(s2_n)]
ok('and the same range the aperture publishes to XNU',
any((pa, pages << xb.GRANULE) == (s2_panel[0], s2_panel[1] - s2_panel[0])
for pa, _, pages in s2_regions),
f'aperture regions {[(hex(pa), hex(pages << 14)) for pa, _, pages in s2_regions]}')
ok('the memory range is unchanged by the panel',
s2_ranges[0][:2] == (dram_lo, dram_hi))
# Refusals say why, and build nothing. The probe refuses the same lists with
# prepare code -10; a quietly partial map would read like the identity map
# this replaces with some addresses missing.
raises('a work area overlapping the memory range is refused',
lambda: xb.stage2_ranges(work=(0xE0000000, 0x2000000)), 'overlaps')
raises('an unaligned work area is refused',
lambda: xb.stage2_ranges(work=(S2_WORK[0] + 0x800, 0x2000000)), 'aligned')
raises('an empty work area is refused',
lambda: xb.stage2_ranges(work=(S2_WORK[0], 0)), 'empty')
raises('a range past the 36-bit IPA space is refused',
lambda: xb.stage2_ranges(work=((1 << 36) - 0x1000, 0x2000)), 'past')
xb.FRAMEBUFFER = (0xF0000000, 2732, 1896, 2732 * 4, 32)
raises('a panel inside the memory range is refused',
lambda: xb.stage2_ranges(work=S2_WORK), 'overlaps')
finally:
xb.FRAMEBUFFER = s2_fb_saved
restore()
# v_display decides whether XNU's console paints glyphs or arms a spinner, and
# only the text arm is the milestone. Pinned because it is a single digit in a
# pack of six, it has no effect until a panel is handed over, and a run with the
# wrong one looks exactly like a run that drew nothing for some other reason.
ok('the panel is handed over in text mode, not graphics mode',
xb.VIDEO_DISPLAY_MODE == 0,
f'v_display would be {xb.VIDEO_DISPLAY_MODE}, which selects the spinner path')
saved_fb = xb.FRAMEBUFFER
try:
xb.FRAMEBUFFER = (0xF9A20000, 2732, 1896, 2732 * 4, 32)
packed = xb.boot_args(0xfffffe0000000000, 0xda000000, 384 * MIB,
0xf2000000, 0xfffffe0006f00000, 0x100)
video = _struct.unpack_from('<6Q', packed, 0x28)
ok('and the packed Video structure says so', video[1] == 0,
f'v_display packed as {video[1]}')
ok('with the panel base in the first field', video[0] == 0xF9A20000)
finally:
xb.FRAMEBUFFER = saved_fb
# MAIR_EL1 is SPTM's, not ours.
#
# There is no `msr MAIR_EL1` in kernelcache.release.mac17p at all, and two in
# sptm.t8140.release. The live one is the second, +0xab5c8 in SPTM's common
# start, which every CPU passes before SPTM's C code and XNU run; the first,
# +0xa01ec (0x010000ff), is only the exclave re-entry stub's, and that stub
# always branches into the common start (tools/test-sptm-mair.py decodes both
# from the image). So the kernel inherits XNU's own CACHE_ATTRINDX table and
# indexes it from its PTEs, and a loader that publishes a different one hands
# XNU indices that mean something else.
_mair = built['mair']
ok('MAIR_EL1 is the value SPTM leaves for XNU', _mair == 0x0C0D00FF01A040FF,
f'{_mair:#018x}')
# Where the stub's 0x010000ff, published until now, differs -- enumerated:
#
# index 1 0x00 Device-nGnRnE -> 0x40 Normal NC (WRITECOMB, FEAT_XS XS=0)
# index 2 0x00 Device-nGnRnE -> 0xa0 Normal write-through (WRITETHRU)
# index 4 0x00 Device-nGnRnE -> 0xff Normal write-back (INNERWRITEBACK)
# index 6 0x00 Device-nGnRnE -> 0x0d Device-GRE, XS=0 (POSTED_REORDERED)
# index 7 0x00 Device-nGnRnE -> 0x0c Device-GRE (POSTED_COMBINED_REORDERED)
#
# Indices 0 (write-back), 3 (0x01, DISABLE) and 5 (0x00, POSTED) keep theirs.
# Index 1 is the one XNU uses for write-combined DRAM: Device-nGnRnE made
# DC ZVA and unaligned accesses to those buffers fault.
_old, _new = 0x00000000010000FF, _mair
_differ = [i for i in range(8)
if (_old >> (8 * i)) & 0xff != (_new >> (8 * i)) & 0xff]
ok('it differs from the stub\'s 0x010000ff at indices 1, 2, 4, 6 and 7',
_differ == [1, 2, 4, 6, 7], f'differs at {_differ}')
ok('...index 1 goes from Device-nGnRnE to Normal non-cacheable',
((_old >> 8) & 0xff, (_new >> 8) & 0xff) == (0x00, 0x40))
ok('...and the device indices 3 and 5 are unchanged',
((_old >> 24) & 0xff, (_new >> 24) & 0xff, (_old >> 40) & 0xff, (_new >> 40) & 0xff)
== (0x01, 0x01, 0x00, 0x00))
# Attribute 0 is the one every loader PTE uses, and it is unchanged. If this
# ever moves, every mapping the loader makes changes meaning at once.
ok('...while attribute 0, which every loader PTE uses, is untouched',
(_old & 0xff) == (_new & 0xff) == 0xFF)
# And no PTE this loader builds uses that index, so the change moves nothing
# that is already mapped. Walked out of the built tables rather than asserted
# from the LEAF constant: LEAF is one default among several map() call sites,
# and a future one passing its own attrs is exactly the case this has to catch.
# Page tables are not in build()'s `chunks` -- they are in tables.pages, and a
# walk over chunks reads zeros and looks like a table that was never built.
_leaves = []
for _page in built['tables'].pages.values():
for _off in range(0, len(_page), 8):
_pte = _struct.unpack_from('<Q', _page, _off)[0]
# A level-3 leaf is 0b11; a block at a higher level is 0b01. Table
# descriptors are also 0b11, so leaves are picked out by having an
# output address inside the memory this layout actually covers rather
# than by their low bits alone -- a table descriptor points at another
# of these pages.
if _pte & 1 and (_pte & 0x0000FFFFFFFFC000) not in built['tables'].pages:
_leaves.append(_pte)
ok('the loader builds leaves at all', len(_leaves) > 100,
f'{len(_leaves)} -- if this is zero the walk found nothing and the two '
f'checks below pass vacuously')
_idx1 = [pte for pte in _leaves if (pte >> 2) & 7 == 1]
ok('no loader-built PTE uses attribute index 1', not _idx1,
f'{len(_idx1)} leaves do, first {_idx1[0]:#018x}' if _idx1 else '')
# No index whose meaning moved is in use, which is what makes this change
# safe rather than merely correct: nothing the loader mapped changes meaning.
_moved = [pte for pte in _leaves if (pte >> 2) & 7 in _differ]
ok('no loader-built PTE uses an index this change moved', not _moved,
f'{len(_moved)} leaves use one of {_differ}')
_indices = sorted({(pte >> 2) & 7 for pte in _leaves})
ok('every loader-built PTE uses attribute index 0', _indices == [0],
f'indices in use: {_indices}')
# The device inventory, and the prediction the span table is measured against.
#
# MAP_PAGE now folds every mapping onto an address outside our memory into a
# span of {va, pa, pages, attrs}, and the host turns each physical base back
# into the node of the guest's own tree that named it. What follows is the
# arithmetic that says what the next run should print, written down BEFORE the
# run so it can be wrong. If the table disagrees, the table is right.
#
# 379 is not a guess: it is the MAP_PAGE-completed counter from the last run,
# and the four groups below add up to it out of the tree alone.
def _pages(size):
"""16K pages a window occupies, rounded up.
Per window, never on a sum: the UART's second window is 0x1000 bytes and
still needs a whole 16K page, so adding the byte counts first and dividing
once loses it -- 0x10000 + 0x1000 divides to four pages where the kernel
maps five. That is exactly the off-by-one this arithmetic is here to get
right."""
return (size + 0x3fff) // 0x4000
_dt = xb.build_devicetree()
ok('XNU timebase comes from the running CPU, not only the root clock',
_dt.get(f'/device-tree/cpus/{xb.BOOT_CPU}', 'timebase-frequency') ==
struct.pack('<I', xb.TIMEBASE_HZ))
_clock_spec = xb.GUEST_PROFILE.counter_clock
_clock_ids = _dt.get(_clock_spec.consumer, 'clock-ids')
_clock_id = struct.unpack_from('<I', _clock_ids, _clock_spec.consumer_index * 4)[0]
ok('watchdog nclk resolves the running CPU fixed frequency',
_clock_id == _clock_spec.clock_id and
_dt.get(_clock_spec.source_cpu, 'fixed-frequency') == struct.pack('<I', xb.TIMEBASE_HZ))
_inv = xb.device_inventory(_dt)
ok('the device inventory finds windows in the guest tree', len(_inv) > 50,
f'{len(_inv)} windows')
# The interrupt controller. One window, and the size is where the 115 comes
# from: 0x1cc000 / 0x4000. The base is the node's `reg` translated through
# arm-io's `ranges`, whose child zero sits at 0x210000000 -- so 0xf1000000
# becomes 0x301000000, which is the address the last run logged.
_aic = xb.device_windows(_dt, 'aic')
ok('the AIC has exactly one register window', len(_aic) == 1, str(_aic))
ok('the AIC window is at 0x301000000', _aic[0][0] == 0x301000000,
f'{_aic[0][0]:#x}')
ok('...which is arm-io child 0xf1000000 through its ranges',
0x210000000 + 0xf1000000 == _aic[0][0])
ok('and it is 115 pages of 16K', _aic[0][1] // 0x4000 == 115,
f'{_aic[0][1]:#x} bytes = {_aic[0][1] / 0x4000} pages')
ok('the AIC window is a whole number of 16K pages', _aic[0][1] % 0x4000 == 0)
# The console. Two windows, NOT one: 0x308128000 for four pages and
# 0x30810c000 for one, and they are not adjacent. That matters for the span
# table -- a span is extended only when the next page continues it in both
# address spaces, so these are two rows however the kernel maps them.
_uart = xb.device_windows(_dt, 'dockchannel-uart')
ok('the dock-channel UART has two register windows', len(_uart) == 2, str(_uart))
ok('...totalling five 16K pages',
sum(_pages(s) for _, s in _uart) == 5, str(_uart))
ok('...which are not contiguous, so they cannot coalesce into one span',
_uart[0][0] + _uart[0][1] != _uart[1][0] and
_uart[1][0] + _uart[1][1] != _uart[0][0], str(_uart))
# The per-core and per-cluster windows. Six cores, five `*-reg` properties
# each, but two of those five are shared between the cores of a cluster --
# acc-impl-reg and cpm-impl-reg name the cluster, not the core -- so the
# distinct spans are 6*3 + 2*2 = 22, not 30.
_cpu = sorted({(pa, size) for pa, size, who in _inv if who.startswith('cpu')})
ok('the per-core and per-cluster windows are 22 distinct spans',
len(_cpu) == 22, f'{len(_cpu)}: {[(hex(p), hex(s)) for p, s in _cpu]}')
ok('...totalling 144 pages of 16K',
sum(_pages(s) for _, s in _cpu) == 144,
str(sum(_pages(s) for _, s in _cpu)))
_predicted = (sum(_pages(s) for _, s in _uart) # the console
+ _pages(_aic[0][1]) * 2 # the AIC, mapped twice
+ sum(_pages(s) for _, s in _cpu)) # the cores
ok('the four groups add up to the 379 maps the last run completed',
_predicted == 379, f'predicted {_predicted}')
# ...and the count of SPANS, which is what the 24-slot table has to hold. This
# is the check that says the prediction does not fit: two UART windows, the AIC
# twice, and 22 core windows is 26 rows, and the table has 24. The overflow
# counter is what keeps that from being a silent truncation, but the shortfall
# is stated here rather than discovered in a transcript.
_predicted_spans = len(_uart) + 2 + len(_cpu)
import run as _span_run
ok('the predicted span count is 26', _predicted_spans == 26,
str(_predicted_spans))
ok('the span table covers both the prediction and the 45 spans observed live',
max(_predicted_spans, 45) <= _span_run.MAP_RANGES)
# The resolver, on the addresses the last run actually logged.
ok('0x301000000 resolves to the AIC',
xb.owner_of(0x301000000, _inv)[0] == 'aic',
str(xb.owner_of(0x301000000, _inv)))
ok('0x308128000 resolves to the dock-channel UART',
xb.owner_of(0x308128000, _inv)[0] == 'dockchannel-uart',
str(xb.owner_of(0x308128000, _inv)))
ok('0x30810c000 resolves to it as well',
xb.owner_of(0x30810c000, _inv)[0] == 'dockchannel-uart',
str(xb.owner_of(0x30810c000, _inv)))
ok('an address inside a window resolves, not just its base',
xb.owner_of(0x301000000 + 0x1cb000, _inv)[0] == 'aic')
ok('and one past its end does not',
xb.owner_of(0x301000000 + 0x1cc000, _inv) is None or
xb.owner_of(0x301000000 + 0x1cc000, _inv)[0] != 'aic')
# An address no node claims is a real answer, printed as one. DRAM is not a
# device and must not be resolved to a neighbouring window by an off-by-one.
ok('an address no node claims resolves to nothing',
xb.owner_of(0xda000000, _inv) is None,
str(xb.owner_of(0xda000000, _inv)))
# Windows nest, and the resolver must name the device rather than the block it
# sits in. This is not hypothetical: pmgr claims 0x308100000 for 0xa8000 and
# the UART's registers are inside it, so taking the first match in address
# order resolves the kernel's console to the power manager. Pinned with both
# the real tree and a synthetic pair, so the property survives a tree that
# stops happening to contain an example of it.
_pmgr = [w for w in _inv if w[2] == 'pmgr' and w[0] <= 0x308128000 < w[0] + w[1]]
ok('the real tree still has a window enclosing the UART\'s', len(_pmgr) == 1,
str(_pmgr))
ok('...and the UART wins it, because its window is the tighter one',
xb.owner_of(0x308128000, _inv)[2] < _pmgr[0][1])
_nested = [(0x1000, 0x8000, 'outer'), (0x2000, 0x1000, 'inner')]
ok('the smallest containing window wins',
xb.owner_of(0x2500, _nested)[0] == 'inner', str(xb.owner_of(0x2500, _nested)))
ok('...and the enclosing one answers where it is the only claim',
xb.owner_of(0x1500, _nested)[0] == 'outer', str(xb.owner_of(0x1500, _nested)))
# The report readers, exercised against a synthetic report.
#
# Every one of these runs exactly once per hardware run, at the end, on a
# machine with one USB link -- so a NameError in one of them costs a run of the
# only device there is. That is not hypothetical: `xb` is a LOCAL of
# run_xnuboot(), and two of these functions were written at module scope
# reaching for it. Nothing offline touched them, so nothing said so.
#
# They all swallow exceptions and print "could not read it", which is the right
# behaviour on the device -- a readback is a nicety and the run's evidence is
# elsewhere -- and is also why these assert on the OUTPUT rather than on not
# raising. A swallowed NameError is a passing test and an empty section.
import contextlib as _contextlib
import io as _io
_spec = importlib.util.spec_from_file_location('run', ROOT / 'probes' / 'run.py')
_run = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_run)
class _FakeProxy:
"""Serves a report buffer the probe would have written, at `out` = 0."""
def __init__(self, size=1 << 16):
self.mem = bytearray(size)
def put(self, off, *words):
for i, w in enumerate(words):
_struct.pack_into('<Q', self.mem, off + 8 * i, w)
def readmem(self, pa, n):
if pa + n > len(self.mem):
raise AssertionError(f'read of {n} at {pa:#x} runs past the report')
return bytes(self.mem[pa:pa + n])
def _capture(fn, *a, **k):
buf = _io.StringIO()
with _contextlib.redirect_stdout(buf):
fn(*a, **k)
return buf.getvalue()
_fake = _FakeProxy()
# Three endpoints called, plus two calls that went elsewhere.
_fake.put(_run.ENDPOINTS_OFFSET + 0x02 * 8, 379)
_fake.put(_run.ENDPOINTS_OFFSET + 0x2d * 8, 1200)
_fake.put(_run.ENDPOINTS_OFFSET + 0x32 * 8, 1)
_fake.put(_run.ENDPOINTS_OFFSET + 64 * 8, 2)
_out = _capture(_run.endpoint_calls, _fake, 0, 1582)
ok('the endpoint table prints SPTM\'s names', 'MAP_PAGE' in _out, _out)
ok('...and the "everything else" bucket', 'another domain' in _out, _out)
ok('...and totals the columns', '1582' in _out, _out)
# Two spans, one of them the AIC, and one that did not fit.
_fake.put(_run.RANGES_OFFSET, 2, 1)
_fake.put(_run.RANGES_OFFSET + 16, 0xfffffe000c838000, 0x301000000, 115, 0x703)
_fake.put(_run.RANGES_OFFSET + 16 + 32, 0xfffffe000c900000, 0x308128000, 4, 0x703)
_out = _capture(_run.map_ranges, _fake, 0, xb, built, 119)
ok('the span table resolves a base to the AIC', 'aic' in _out, _out)
ok('...and to the dock-channel UART', 'dockchannel-uart' in _out, _out)
ok('...and says when spans did not fit', 'did not fit' in _out, _out)
ok('the span reader never reports a NameError or the like',
'could not' not in _out and 'Error' not in _out, _out)
# The guest's translation registers, with MAIR as we published it.
_fake.put(_run.GUEST_SYSREGS_OFFSET, 0, 0x1234000, 0x1234, built['mair'], 0)
_out = _capture(_run.guest_sysregs, _fake, 0, built)
ok('the sysreg readback prints MAIR', 'MAIR_EL1' in _out, _out)
ok('...and says it is unchanged when it matches what we published',
'unchanged' in _out, _out)
ok('the sysreg readback does not fall into its except',
'could not read' not in _out, _out)
# ...and notices when it does not match, which is the whole point of reading it.
_fake.put(_run.GUEST_SYSREGS_OFFSET + 3 * 8, 0x44FF)
_out = _capture(_run.guest_sysregs, _fake, 0, built)
ok('...and says CHANGED when MAIR is not what we published',
'CHANGED' in _out, _out)
# The two measured addresses, pinned. Nothing re-derives these at run time, and
# both ship as values common in __DATA_CONST -- 0x01 and null -- so a digit
# wrong reads some other variable and prints a confident number about it. The
# offsets within the page are what distinguish them; 0x160 and 0x220 are 0xc0
# apart and a transposition lands on neither.
ok('the kprintf gate address is the one measured in mac17p',
_run.KPRINTF_GATE == 0xfffffe0007ebf160, f'{_run.KPRINTF_GATE:#x}')
ok('and PE_kputc\'s is too', _run.PE_KPUTC == 0xfffffe0007ebf220,
f'{_run.PE_KPUTC:#x}')
ok('both are in the same __DATA_CONST page, 0xc0 apart',
_run.PE_KPUTC - _run.KPRINTF_GATE == 0xc0)
# And they are addresses this loader actually uploads, so `peek` can reach
# them. An address outside every chunk would print "could not read it" on the
# device and the run would be spent.
for _va, _what in ((_run.KPRINTF_GATE, 'the gate'), (_run.PE_KPUTC, 'PE_kputc')):
try:
_run.uploaded(built, _va, 4)
ok(f'{_what} is inside a chunk the loader uploads', True)
except KeyError as _exc:
ok(f'{_what} is inside a chunk the loader uploads', False, str(_exc))
# The kprintf gate, against the bytes the loader actually uploaded.
_out = _capture(_run.kprintf_gate,
lambda va, n=8: _run.uploaded(built, va, n), built)
ok('the kprintf readback runs without falling into its except',
'could not read it' not in _out, _out)
ok('...and reads the shipped 0x01 at +0x160', '0x01 (image ships 0x01)' in _out,
_out)
ok('...and reports PE_kputc null in the image as shipped',
'still null' in _out, _out)
ok('...and says witness 3 cannot arrive while bit 0 is set',
'witness 3 cannot arrive' in _out, _out)
# The other arm: a gate that opened and a function pointer installed.
ok('...and still names the closed serial-init gate when only +0x160 opens',
'serial-init gate is still closed' in _capture(
_run.kprintf_gate,
lambda va, n=8: (0x00000100 if va == 0xfffffe0007ebf160
else 0xfffffe000b91c000), built))
_refs_tables = xb.Tables(0x90000000)
_refs_aperture = 0xfffffe0100000000
_refs_tables.map(_refs_aperture, 0x91000000, 0x4000)
_refs_tables.validate_allocator_baseline(0x91000000, 0x91004000,
_refs_aperture, 0x91000000)
ok('physical aperture leaves the initial counted baseline empty', True)
_refs_tables.map(0xfffffe0200000000, 0x91000000, 0x4000)
raises('a preinstalled allocator alias needs reference seeding',
lambda: _refs_tables.validate_allocator_baseline(
0x91000000, 0x91004000, _refs_aperture, 0x91000000), 'alias already exists')
raises('bootstrap tables cannot occupy counted allocator frames',
lambda: _refs_tables.validate_allocator_baseline(
0x90000000, 0x90010000, _refs_aperture, 0x91000000), 'bootstrap table')
print(f'PASS: {checks} profile and layout checks '
'(offline, no hardware or proxy; local firmware integration)')