Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions boot/initramfs/root/init
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/bin/busybox sh
# orchard's initrd for installed systems: load storage, mount the root that
# root=PARTUUID= names, start its init. The same for every distribution and
# root=PARTUUID= names (on a GPT or LwVM disk), start its init. The same for every distribution and
# init system; only BusyBox and the platform's storage modules are inside.
export PATH=/bin
/bin/busybox --install -s /bin
Expand Down Expand Up @@ -53,6 +53,16 @@ gpt_partuuid() {
echo "$4$3$2$1-$6$5-$8$7-$9${10}-${11}${12}${13}${14}${15}${16}"
}

# lwvm_partuuid DISK NUMBER: the GUID of LwVM entry NUMBER (32-bit boards' iOS
# disk), from the header the kernel's LwVM reader reads too; its partition
# numbers are the entries' numbers.
lwvm_partuuid() {
[ "$(od -An -tx1 -v -N 16 "$1" | tr -d ' \n')" = 6a9088cf8afd630ae351e24887e0b98b ] || return 1
set -- $(od -An -tx1 -v -j $((512 + ($2 - 1) * 128 + 16)) -N 16 "$1")
[ "$*" != '00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00' ] || return 1
echo "$1$2$3$4-$5$6-$7$8-$9${10}-${11}${12}${13}${14}${15}${16}"
}

device=
seconds=0
while [ -z "$device" ]; do
Expand All @@ -61,7 +71,10 @@ while [ -z "$device" ]; do
part=${number%/partition}
disk=$(basename "$(dirname "$(readlink -f "$part")")")
sector=$(cat "/sys/class/block/$disk/queue/logical_block_size")
if [ "$(gpt_partuuid "/dev/$disk" "$sector" "$(cat "$number")")" = "$root" ]; then
# A GPT first, as always; only a disk without one is asked for LwVM.
id=$(gpt_partuuid "/dev/$disk" "$sector" "$(cat "$number")") ||
id=$(lwvm_partuuid "/dev/$disk" "$(cat "$number")")
if [ "$id" = "$root" ]; then
device=/dev/${part##*/}
break
fi
Expand Down
9 changes: 6 additions & 3 deletions devices/p105/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ public recipe or release.
`kernel/patches/armv7/mtd`; the disk is `applesftlN`, on the 64 GB model
only. Reclamation (#93) is unfinished: until it lands, writes stop once
the blank flash blocks are used up.
- A Linux root on LwVM: the kernel reads LwVM partitions
(`kernel/patches/armv7/block`), but the shared initrd finds its root by
reading a GPT itself and needs to learn LwVM partitions.
- Installing: `orchard install` writes a root into an existing Linux LwVM
partition (the shared initrd finds it by its LwVM GUID); creating that
partition belongs to the iOS resize step, which does not exist yet. There
is no boot partition: starting the installed system later needs the
Cascadia bundle the install leaves on the host, placed for iOS's boot
chain. Starting any payload needs `orchard-launcher` (recovery-autogo).
- USB: the cold-handoff topic (#90) awaits a device boot of its exact
revision, and the iPad 3 (J1) a regression boot of the shared PHY code;
see [USB handoff](../../docs/bring-up/p105-usb-handoff.md).
Expand Down
7 changes: 7 additions & 0 deletions devices/p105/device.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ dtb = "s5l8942x-p105" # arch/arm/boot/dts/apple/<dtb>.dtb
[firmware]
recipe = "firmware.json"

# The iOS disk's translation layer writes append-only: until reclamation lands
# (OrchardLinux/orchard#93), freed space is never reused and writes fail once
# the blank flash runs out. Drop this table when it lands; the installer
# warns from it and allows the install either way.
[storage]
reclaims = false

[support]
status = "experimental"

Expand Down
15 changes: 15 additions & 0 deletions docs/boot-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ which one and opens a shell on the console.
- **Partition identity only.** Roots are named by PARTUUID in their entries, never by partition number or position.
- **Inside its root partition, a system uses its native layout,** for example upstream Omarchy's btrfs subvolumes.

## Partitions (LwVM, 32-bit boards, interim)

The iOS disk of 32-bit boards is partitioned with LwVM, which the kernel reads
(`kernel/patches/armv7/block`): partitions keep their LwVM entry number, and the
entry's GUID is their PARTUUID. Until the iOS resize step creates the Linux
entry, the installer only replaces an existing, unencrypted Linux entry
(`0fc63daf-8483-4772-8e79-3d69d8477de4`) in place and never writes the LwVM map.
There is no boot partition yet: the entry and its files are written to the
root's own `/boot`, in the layout below, for a later boot partition or loader to
take over. Meanwhile the host keeps the Cascadia bundle that starts the
installed system (its tree's `bootargs` carry `root=PARTUUID=`), which has to be
placed where iOS's boot chain finds it.

## Entries

The layout follows BLS:
Expand All @@ -68,6 +81,8 @@ devicetree /6a1f…/6.18.2-orchard/t8010-j71b.dtb
options root=PARTUUID=4d6c…-… rw quiet
```

`architecture` is `aa64`, or `arm` for a 32-bit zImage.

| Key | Orchard rule |
|---|---|
| `linux` | Required. arm64 `Image`, raw or gzip-compressed |
Expand Down
9 changes: 9 additions & 0 deletions schemas/v1/device.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@
],
"additionalProperties": false
},
"storage": {
"type": "object",
"properties": {
"reclaims": {
"type": "boolean"
}
},
"additionalProperties": false
},
"support": {
"type": "object",
"properties": {
Expand Down
10 changes: 9 additions & 1 deletion src/orchard/apple/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from orchard.apple import entries, usb, boot as apple_boot, adt_capture
from orchard.apple.adt_tree import parse
from orchard.transports import ssh
from orchard import firmware, hardware
from orchard import firmware, hardware, launcher
from .adt import validate as validate_adt


Expand All @@ -40,6 +40,12 @@ def __init__(self, descriptor: Dict[str, Any]):
self.INSTALL = not self.INSTALL_BLOCKERS
self.STATUS = support.get('status', 'experimental')
self.FEATURES = dict(descriptor.get('features', {}))
# Facts about the internal storage that the installer tells the person (service.caveats).
self.STORAGE = dict(descriptor.get('storage', {}))
# The boot method orchard-launcher starts this board with, when no method of orchard's own fits.
listed = descriptor.get('boot', {}).get('methods', [])
self.LAUNCHER_CHAIN = None if any(name in hardware.IMPLEMENTED_METHODS for name in listed) else \
next((name for name in listed if launcher.delivers(name)), None)
self.REBOOT_MESSAGE = ('A fresh recovery boot is required.\n'
f'Put the {self.NAME} in DFU mode, then run: orchard install')
self.firmware_inputs = partial(firmware.ensure, self.PROFILE)
Expand Down Expand Up @@ -83,6 +89,8 @@ def discover(self) -> SelectedDevice:
return cast(SelectedDevice, {**device, 'resumed': False})

def boot_inputs(self) -> List[Any]:
if self.LAUNCHER_CHAIN:
return [] # the launcher brings what its chain needs
return entries.inputs(entries.select(self._descriptor))

def boot(self, device: SelectedDevice, assets: BootAssets,
Expand Down
17 changes: 15 additions & 2 deletions src/orchard/apple/linux_usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@
MODES = {0x1227: 'dfu', 0x4141: 'pongo', 0x4142: 'linux', 0x4144: 'ram', 0x4145: 'system'}


def serial_tags(serial):
"""What an Apple USB serial string says beyond ECID, CPID and BDID.

serial_number: the device's serial number (DFU's SRNM:[...]); pwned: the
exploit that left its PWND:[...] tag (checkm8, ...), or checkra1n for its
YOLO:checkra1n tag; None when absent.
"""
number = re.search(r'\bSRNM:\[([A-Za-z0-9]{1,32})\]', serial)
pwned = re.search(r'\bPWND:\[([^\]]{1,64})\]', serial)
return {'serial_number': number.group(1) if number else None,
'pwned': pwned.group(1) if pwned else ('checkra1n' if 'YOLO:checkra1n' in serial else None)}


def devices(root=Path('/sys/bus/usb/devices')):
found = []
for node in root.iterdir():
Expand All @@ -32,8 +45,8 @@ def devices(root=Path('/sys/bus/usb/devices')):
boot_id=str(uuid.UUID(boot.group(1))) if boot else None,
chip=int(chip.group(1), 16) if chip else None,
board=int(board.group(1), 16) if board else None,
pwned='YOLO:checkra1n' in serial, profile=profile.group(1) if profile else None, product=product,
serial_port=next(iter(ports), None)))
profile=profile.group(1) if profile else None, product=product,
serial_port=next(iter(ports), None), **serial_tags(serial)))
return found


Expand Down
4 changes: 2 additions & 2 deletions src/orchard/apple/usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import sys
import uuid

from .linux_usb import devices as linux_devices, m1n1_ports as linux_m1n1_ports
from .linux_usb import devices as linux_devices, m1n1_ports as linux_m1n1_ports, serial_tags
from orchard.state import EndpointUnavailable

# 0x4144: a RAM payload's USB session (orchard/usb-session).
Expand Down Expand Up @@ -58,7 +58,7 @@ def devices():
'board': int(fields[2].group(1), 16) if fields[2] else None,
'profile': profile.group(1) if profile else None,
'product': node.get('USB Product Name'),
'serial_port': ports[0] if ports else None})
'serial_port': ports[0] if ports else None, **serial_tags(serial)})
return found


Expand Down
23 changes: 23 additions & 0 deletions src/orchard/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ def boot_ram(output, *, yes=False, logs=None):
print('WARNING: The intended device for this build is unknown.')
print('Compatibility with your connected device has not been established.')
print('It may fail to boot or stop responding until you restart it.')
if receipt.get('loader_format') == 'cascadia':
# 32-bit boards: orchard-launcher takes the device from DFU to the payload (launcher.py).
if not yes:
confirm_boot(mismatch=mismatch)
verify_ram(output)
start_cascadia(output, receipt, device)
print('The launcher reports the payload started; Linux readiness has NOT been verified.')
return
# Before the question, so that it can finish while it is being read.
entry = entries.select(description if not mismatch else None)
fetching = Background(fetch_chain, entries.inputs(entry) if device['mode'] == 'dfu' else [], Progress())
Expand All @@ -90,6 +98,21 @@ def boot_ram(output, *, yes=False, logs=None):
print('or screen /dev/ttyACM0 115200 (Linux; verify the actual port).')


def start_cascadia(output, receipt, device):
"""The Cascadia payload in OUTPUT through orchard-launcher, with the device's serial number in its tree."""
import tempfile
from . import launcher
from .hardware import data_root
from .image import cascadia
from .progress import Progress
image = Path(output) / 'linux.bin'
with tempfile.TemporaryDirectory(prefix='payload-') as directory:
if device.get('serial_number'):
image = Path(directory) / 'linux.bin'
image.write_bytes(cascadia.with_serial((Path(output) / 'linux.bin').read_bytes(), device['serial_number']))
launcher.load(image, chain=receipt.get('boot_method', 'recovery-autogo'), catalog=data_root(), progress=Progress())


def run_directory(directory, *, yes=False, build=True, device=None, platform=None, **build_options):
"""Build what is missing or outdated for the attached board, then boot it once.

Expand Down
3 changes: 2 additions & 1 deletion src/orchard/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ class _SelectionDetails(TypedDict, total=False):
board: Optional[int]
product: Optional[str]
serial_port: Optional[str]
pwned: bool
pwned: Optional[str]
serial_number: Optional[str]
boot_id: Optional[str]


Expand Down
22 changes: 18 additions & 4 deletions src/orchard/hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,16 @@ def install_blockers(profile, described=None):
as soon as it could work, even if other features don't work yet.
"""
described = device(profile) if described is None else described
from . import launcher
blockers = []
if 'kernel' not in described:
blockers.append('it has no kernel or device tree yet')
if not any(_method_usable(profile, name) for name in described.get('boot', {}).get('methods', [])):
blockers.append('this Orchard has no boot method for it yet')
listed = described.get('boot', {}).get('methods', [])
if not any(_method_usable(profile, name) for name in listed):
if any(launcher.delivers(name) and _method_listed(profile, name) for name in listed) and launcher.find() is None:
blockers.append(f'it starts through {launcher.NAME}, which is not installed')
else:
blockers.append('this Orchard has no boot method for it yet')
if 'firmware' not in described:
blockers.append('it has no firmware recipe yet')
if described.get('features', {}).get('storage_write', 'missing') == 'missing':
Expand All @@ -125,9 +130,18 @@ def has_recovery(described, root=None):
return False


def method_implemented(name):
"""Whether this Orchard can run boot method NAME: its own code, or orchard-launcher when installed."""
from . import launcher
return name in IMPLEMENTED_METHODS or (launcher.delivers(name) and launcher.find() is not None)


def _method_usable(profile, name):
if name not in IMPLEMENTED_METHODS:
return False
return method_implemented(name) and _method_listed(profile, name)


def _method_listed(profile, name):
"""Whether the board has what method NAME needs: its boot.json recipe, where the method takes one."""
if not methods()[name]['boot_json']:
return True
recipe = device_path(profile).parent / 'boot.json'
Expand Down
69 changes: 64 additions & 5 deletions src/orchard/image/cascadia.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
where the initramfs is; it copies it to the profile's initrd address before
it turns off the MMU and enters the unchanged zImage, as Project Cascadia's
linux_boot does. The DTB's /chosen linux,initrd-start/-end, placeholders in
the board's tree, are set in place to that copy.
the board's tree, are set in place to that copy. The host may also give the
device's serial number, which goes to the tree's root as serial-number (what
m1n1 copies from Apple's tree on 64-bit boards), and boot arguments for an
installed system, which replace /chosen bootargs.
This module uses only the standard library and also runs by file path.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import struct
import tomllib
import zlib
Expand Down Expand Up @@ -284,6 +288,37 @@ def set_initrd(data, start, size):
return bytes(result)


SERIAL = re.compile(r'[A-Za-z0-9]{1,32}')


def set_properties(data, *, serial=None, bootargs=None):
"""DATA with the root's serial-number and /chosen bootargs set; unchanged when neither is given."""
if serial is None and bootargs is None:
return data
root, reservations, cpu = read_fdt(data)
if serial is not None:
if not SERIAL.fullmatch(serial):
raise ValueError('Expected a serial number of letters and digits')
root['properties']['serial-number'] = serial.encode() + b'\0'
if bootargs is not None:
if any(ord(c) < 0x20 or ord(c) > 0x7e for c in bootargs):
raise ValueError('Boot arguments must be printable ASCII on one line')
chosen = [child for child in root['children'] if child['name'] == 'chosen']
if not chosen:
raise ValueError('The DTB has no /chosen for boot arguments')
chosen[0]['properties']['bootargs'] = bootargs.encode() + b'\0'
return write_fdt(root, reservations, cpu)


def board_bootargs(data):
"""The board tree's own /chosen bootargs, or ''."""
root, _, _ = read_fdt(data)
for child in root['children']:
if child['name'] == 'chosen':
return child['properties'].get('bootargs', b'').rstrip(b'\0').decode('ascii', 'replace')
return ''


def decompression_bound(kernel, dtb_size, described):
"""Conservatively bound the v6.12 ARM decompressor's relocated workspace.

Expand Down Expand Up @@ -318,12 +353,14 @@ def decompression_bound(kernel, dtb_size, described):
return bound


def assemble(loader, kernel, dtb, initramfs, described, *, elf=None):
def assemble(loader, kernel, dtb, initramfs, described, *, elf=None, serial=None, bootargs=None):
"""(image, receipt): LOADER, the unchanged zImage KERNEL with DTB appended, then INITRAMFS.

The loader copies INITRAMFS to the profile's initrd address before it
turns off the MMU, and the DTB's /chosen says where it is."""
turns off the MMU, and the DTB's /chosen says where it is. SERIAL and
BOOTARGS: see set_properties."""
validate_profile(described)
source_dtb, dtb = dtb, set_properties(dtb, serial=serial, bootargs=bootargs)
verify_loader(loader, described, elf)
if (len(kernel) < 0x30 or struct.unpack_from('<I', kernel, 0x24)[0] != 0x016f2818):
raise ValueError('Expected an ARM zImage')
Expand Down Expand Up @@ -366,22 +403,44 @@ def append(name, payload):
'dtb_address': described['load_address'] + dtb_at,
'kernel_decompression_bound': expanded_end,
'initramfs_address': described['initrd_address'],
'input_loader_sha256': digest(loader), 'input_dtb_sha256': digest(dtb)}
'input_loader_sha256': digest(loader), 'input_dtb_sha256': digest(source_dtb)}


def with_serial(image, serial):
"""IMAGE, an assembled payload, with its tree's serial-number set: reassembled from its own parts.

A RAM payload is built before the device is known; the host adds the
serial number of the device it boots just before handing it over.
"""
config = configuration(image)
described = {'schema': 1, **{field: config[field] for field in PROFILE_FIELDS - {'schema'}}}
loader = bytearray(image[:config['kernel_offset']])
pristine = {**config, **{field: 0 for field in ('dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size',
'image_size', 'crc32')}}
loader[CONFIG_OFFSET:CONFIG_OFFSET + CONFIG.size] = CONFIG.pack(*(pristine[field] for field in CONFIG_FIELDS))
at = config['kernel_offset']
start, end = struct.unpack_from('<II', image, at + 0x28)
kernel = image[at:at + end - start]
tree = image[config['dtb_offset']:config['dtb_offset'] + config['dtb_size']]
initramfs = image[config['initrd_offset']:config['initrd_offset'] + config['initrd_size']]
return assemble(bytes(loader), kernel, tree, initramfs, described, serial=serial)[0]


def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
for name in ('loader', 'kernel', 'dtb', 'initramfs', 'profile', 'output'):
parser.add_argument('--' + name, type=Path, required=True)
parser.add_argument('--loader-elf', type=Path)
parser.add_argument('--serial', help="the device's serial number, for the tree's serial-number")
args = parser.parse_args()
receipt = args.output.with_suffix(args.output.suffix + '.json')
if args.output.exists() or receipt.exists():
parser.error('Choose new image and receipt paths')
try:
data, document = assemble(args.loader.read_bytes(), args.kernel.read_bytes(), args.dtb.read_bytes(),
args.initramfs.read_bytes(), profile(args.profile),
elf=args.loader_elf.read_bytes() if args.loader_elf else None)
elf=args.loader_elf.read_bytes() if args.loader_elf else None,
serial=args.serial)
except (ValueError, KeyError) as error:
parser.error(str(error))
document['profile_sha256'] = digest(args.profile.read_bytes())
Expand Down
Loading
Loading