Skip to content
This repository was archived by the owner on Oct 30, 2026. It is now read-only.
Closed
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
6 changes: 6 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ framing are informed by Corellium's Project Sandcastle and Paul Praschl's
(Pauli1Go) makez2fw:
https://github.com/Pauli1Go/HoolockLinux-linux-firmware
Reference revision: 944541e67b4c603df328c77d30093551de802c17.
J71t touch packet selection and ADT calibration copies (j71t/touch.py,
j71t/calibration.py): GPL-2.0-or-later, from the omarchy-ipad5 iPad 5 port
(https://github.com/brkosebay/omarchy-ipad5), which builds on Paul Praschl's
(Pauli1Go) HoolockLinux A10 work.
VXD firmware record layout (apple/d5500.py): GPL-2.0-or-later, from the
omarchy-ipad5 video decoder work, which reads the same record.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

Host-side tools for installing, booting, and debugging Linux on supported Apple devices.

## Supported devices

| Device | Identifier | Profile |
| --- | --- | --- |
| iPad 6 Wi-Fi | `iPad7,5` / J71b | `j71b` |
| iPad 5 Wi-Fi (TSMC) | `iPad6,11` / J71t (S8003) | `j71t` |

The connected device selects its adapter. `orchard devices` lists observations from every adapter; the other iPad 5 Wi-Fi variant, J71s (S8000), is not supported.

## Install or update

Requires Linux or macOS, Python 3.9+ with venv/pip, libusb, and OpenSSH. Linux also requires sudo, udev, and [apfs-fuse](https://github.com/sgan81/apfs-fuse) with its FUSE runtime for firmware extraction. Install `apfs-fuse` separately and put it in PATH, or set `IPSW_APFS_FUSE_PATH` to its executable. `setup-host` checks for the executable before downloading tools or installing USB rules.
Expand Down
10 changes: 6 additions & 4 deletions docs/adapter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
| Member | Contract and effects |
| --- | --- |
| `PROFILE`, `NAME`, `REBOOT_MESSAGE` | Registered profile key, human-readable name and fresh-boot instructions. The profile must match its registry entry. |
| `list_devices()` | Raw discovery observations for display; read-only, no boot/preparation or implicit adapter selection. Vendor-specific observation fields are not interpreted by the common workflow. |
| `discover()` | Read-only identification of exactly one supported device. Return a validated `SelectedDevice`; raise `ValueError` when no unique supported device is available. |
| `list_devices()` | Raw discovery observations for display; read-only, no boot/preparation or implicit adapter selection. Vendor-specific observation fields are not interpreted by the common workflow. `orchard devices` merges every registered adapter's observations without duplicates. |
| `discover()` | Read-only identification of exactly one supported device. Return a validated `SelectedDevice`; raise `UnsupportedDevice` (a `ValueError`) when the connected target is another model, and `ValueError` when no unique supported device is available. |
| `boot(device, assets, manifest, progress)` | Enter recovery using already-verified boot artifacts. A resumed recovery is not rebooted/uploaded. Keep identity/port checks and timeout budgets; never retry an uncertain mutation. |
| `connect(device)` | Context-managed serial bootstrap and pinned SSH access bound to the selected ECID/boot. No host-side device storage/service recipes. |
| `connect_saved(device)` | Context-managed use of the selected device/boot's retained pinned credentials. Never reprovision credentials or drive a login prompt. |
Expand All @@ -17,6 +17,8 @@
| `firmware_inputs(clock, progress)` | Return verified cached/acquired host firmware inputs; account download time separately. No device hardware or storage mutation. |
| `prepare_mixin(adt, firmware_dir, outdir)` | Return `(Path, MixinReceipt)` for a private data-only archive bound to the selected device/recipe. Preserve firmware hash, containment, calibration and output guards. |

`devices.choose()` tries every registered adapter. Exactly one adapter must return a selection; `UnsupportedDevice` refusals are not errors, identical shared USB errors are reported once, and a target that no adapter supports is reported with the list of supported devices. `orchard mixin` selects the profile whose hardware descriptor lists the ADT's `model`, or takes an explicit `--profile`; the adapter still validates the ADT itself.

Arguments and return types are defined in `contracts.py`, not repeated in a second runtime schema. Image-facing protocol validation, manifest validation and hardware identity verification remain in their existing owners.

## Records
Expand All @@ -31,14 +33,14 @@ TypedDict records describe the boundary. They do not replace validation of untru

## Enforcement

The registry checks required member presence, profile identity and presentation metadata without invoking discovery. Python runtime Protocol checks do not verify function signatures; static checking does that. The real J71b module is assigned directly to `Adapter` in `tests/typecheck_adapter.py`, so signature drift is rejected without creating a wrapper implementation.
The registry checks required member presence, profile identity and presentation metadata without invoking discovery. Python runtime Protocol checks do not verify function signatures; static checking does that. The real J71b and J71t modules are assigned directly to `Adapter` in `tests/typecheck_adapter.py`, so signature drift is rejected without creating a wrapper implementation.

From `cli/`:

```sh
uv tool run --from 'mypy==1.18.2' mypy --config-file pyproject.toml
```

The development-only checker is declared in the dev dependency group, not runtime dependencies. It targets Python 3.9 and checks the contract, registry, registered implementation and host command/workflow call sites. The wheel includes `py.typed` for consumers of this interface.
The development-only checker is declared in the dev dependency group, not runtime dependencies. It targets Python 3.9 and checks the contract, registry, registered implementations and host command/workflow call sites. The wheel includes `py.typed` for consumers of this interface.

Unit tests cover conformance/member/profile checks, loader non-mutation and explicit resume state. Existing CLI tests continue covering identity binding, uncertain-outcome/no-retry behavior, private archives, offline imports, source-denied wheel operation and SSH retirement. These contract checks require no physical device.
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ orch = "orchard_cli.command:main"

[tool.setuptools]
include-package-data = false
packages = ["orchard_cli", "orchard_cli.j71b", "orchard_cli.apple", "orchard_cli.transports", "orchard_cli_schemas"]
packages = ["orchard_cli", "orchard_cli.j71b", "orchard_cli.j71t", "orchard_cli.apple", "orchard_cli.transports", "orchard_cli_schemas"]
package-dir = {"" = "src", orchard_cli_schemas = "schemas"}

[tool.setuptools.package-data]
Expand All @@ -36,12 +36,12 @@ dev = ["mypy==1.18.2"]
[tool.mypy]
python_version = "3.9"
mypy_path = "src"
files = ["src/orchard_cli/contracts.py", "src/orchard_cli/devices.py", "src/orchard_cli/j71b/adapter.py", "src/orchard_cli/j71b/inputs.py", "src/orchard_cli/j71b/mixin.py", "src/orchard_cli/service.py", "src/orchard_cli/command.py", "tests/typecheck_adapter.py"]
files = ["src/orchard_cli/contracts.py", "src/orchard_cli/devices.py", "src/orchard_cli/j71b/adapter.py", "src/orchard_cli/j71b/inputs.py", "src/orchard_cli/j71b/mixin.py", "src/orchard_cli/j71t/adapter.py", "src/orchard_cli/j71t/inputs.py", "src/orchard_cli/j71t/mixin.py", "src/orchard_cli/j71t/calibration.py", "src/orchard_cli/j71t/touch.py", "src/orchard_cli/service.py", "src/orchard_cli/command.py", "tests/typecheck_adapter.py"]
check_untyped_defs = true
follow_imports = "silent"
ignore_missing_imports = true
warn_unused_ignores = true

[[tool.mypy.overrides]]
module = ["orchard_cli.contracts", "orchard_cli.devices", "orchard_cli.j71b.adapter", "orchard_cli.j71b.inputs", "orchard_cli.j71b.mixin"]
module = ["orchard_cli.contracts", "orchard_cli.devices", "orchard_cli.j71b.adapter", "orchard_cli.j71b.inputs", "orchard_cli.j71b.mixin", "orchard_cli.j71t.adapter", "orchard_cli.j71t.inputs", "orchard_cli.j71t.mixin", "orchard_cli.j71t.calibration", "orchard_cli.j71t.touch"]
disallow_untyped_defs = true
68 changes: 68 additions & 0 deletions src/orchard_cli/apple/d5500.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Extract the VXD (DEVA) video-decoder firmware from a legacy prelinked kernelcache.

AppleD5500 has no __firmware section. Its __DATA.__data holds a record

+0x00 char *name -> __TEXT.__cstring ("REL_812.0_14_Deva")
+0x08 char *build -> __TEXT.__cstring (build date)
+0x10 u32 mtx_size (memory the decoder core needs; <= image size)
+0x14 u32 size (image size)
+0x18 u8 *image -> __TEXT.__const

The record is found by that pointer pattern, not by fixed offsets, and the
image is copied unchanged. The caller pins its SHA-256.
"""
import argparse
import hashlib
import json
from pathlib import Path
import struct
from typing import Any, Dict, Tuple

from orchard_cli.apple.prelink import Kernelcache, named

D5500 = 'com.apple.driver.AppleD5500'
DATA, CSTRING, CONST = ('__DATA', '__data'), ('__TEXT', '__cstring'), ('__TEXT', '__const')
RECORD = struct.Struct('<QQIIQ')


def extract(blob: bytes) -> Tuple[bytes, Dict[str, Any]]:
kernel = Kernelcache(blob)
sections = named(kernel.kext_sections(D5500, 'AppleD5500'), DATA, CSTRING, CONST)

def inside(addr: int, key: Tuple[str, str], size: int = 1) -> bool:
s = sections[key]
return bool(s['addr'] <= addr and addr + size <= s['addr'] + s['size'])

def cstring(addr: int) -> str:
s = sections[CSTRING]
text = kernel.read(addr, min(64, s['addr'] + s['size'] - addr)).split(b'\0')[0]
value = text.decode('ascii', 'replace')
return value if value.isprintable() and '�' not in value else ''

data = kernel.read(sections[DATA]['addr'], sections[DATA]['size'])
records = []
for offset in range(0, len(data) - RECORD.size + 1, 8):
name, build, mtx_size, size, image = RECORD.unpack_from(data, offset)
if (inside(name, CSTRING) and inside(build, CSTRING) and 4096 < size <= 4 * 1024 * 1024
and mtx_size <= size and inside(image, CONST, size) and cstring(name).endswith('_Deva')):
records.append((cstring(name), cstring(build), image, size))
if len(records) != 1:
raise ValueError('No unique VXD firmware record')
name, build, image, size = records[0]
firmware = kernel.read(image, size)
report = dict(kext=D5500, firmware=name, build=build, kernel_file_offset=kernel.offset(image, size),
firmware_size=size, sha256=hashlib.sha256(firmware).hexdigest())
return firmware, report


if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('kernel', type=Path)
parser.add_argument('output', type=Path)
args = parser.parse_args()
data, report = extract(args.kernel.read_bytes())
# Never silently replace a firmware artifact.
with args.output.open('xb') as out:
out.write(data)
print(json.dumps(report, indent=2))
57 changes: 57 additions & 0 deletions src/orchard_cli/apple/prelink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Locate one kext in a legacy prelinked ARM64 kernelcache.

No hardcoded offsets: the kext is selected by its prelink metadata, and its
virtual addresses are mapped through the outer kernel's file-backed segments.
For MH_FILESET kernels use the upstream ipsw kext extractor instead.
"""
from typing import Any, Dict, List, Tuple

from orchard_cli.apple.macho import macho
from orchard_cli.apple.osserialize import apple_xml

Section = Dict[str, Any]


class Kernelcache:
def __init__(self, blob: bytes):
kind, self.segments, self.sections = macho(blob)
if kind != 2:
raise ValueError('Expected legacy MH_EXECUTE kernelcache')
self.blob = blob

def offset(self, addr: int, size: int) -> int:
offsets = {s['offset'] + addr - s['vm'] for s in self.segments
if s['vm'] <= addr and addr + size <= s['vm'] + s['size']}
if len(offsets) != 1:
raise ValueError('Section has no unique file-backed mapping')
offset = offsets.pop()
if offset + size > len(self.blob):
raise ValueError('Section exceeds file')
return offset

def read(self, addr: int, size: int) -> bytes:
offset = self.offset(addr, size)
return self.blob[offset:offset + size]

def kext_sections(self, bundle: str, label: str) -> List[Section]:
"""The sections of the one prelinked kext with this bundle identifier."""
info = [s for s in self.sections if (s['segment'], s['name']) == ('__PRELINK_INFO', '__info')]
if len(info) != 1:
raise ValueError('No unique prelink metadata section')
metadata = apple_xml(self.read(info[0]['addr'], info[0]['size']))
kexts = [k for k in metadata['_PrelinkInfoDictionary'] if k.get('CFBundleIdentifier') == bundle]
if len(kexts) != 1:
raise ValueError(f'No unique {label} kext')
_, _, sections = macho(self.blob, self.offset(kexts[0]['_PrelinkExecutableLoadAddr'], 32))
return sections


def named(sections: List[Section], *keys: Tuple[str, str]) -> Dict[Tuple[str, str], Section]:
"""Exactly one section per (segment, section) key."""
result = {}
for key in keys:
found = [s for s in sections if (s['segment'], s['name']) == key]
if len(found) != 1:
raise ValueError('No unique section %s.%s' % key)
result[key] = found[0]
return result
43 changes: 43 additions & 0 deletions src/orchard_cli/apple/smartio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Extract SmartIO __DATA.__firmware from a legacy prelinked ARM64 kernelcache.

No hardcoded offsets; select the exact kext via its prelink metadata, then map
its section virtual address through the outer kernel's file-backed segments.
For MH_FILESET kernels use the upstream ipsw kext extractor instead. The kext
bundle identifier is the caller's board policy (T8010 by default; S8000 for A9).
"""
import argparse
import hashlib
import json
from pathlib import Path

from orchard_cli.apple.prelink import Kernelcache

T8010 = 'com.apple.driver.AppleT8010SmartIO'
S8000 = 'com.apple.driver.AppleS8000SmartIO'


def extract(blob, bundle=T8010):
kernel = Kernelcache(blob)
ksections = kernel.kext_sections(bundle, 'SmartIO')
firmware = [s for s in ksections if (s['segment'], s['name']) == ('__DATA', '__firmware')]
if len(firmware) != 1 or not 4096 <= firmware[0]['size'] <= 4*1024*1024:
raise ValueError('No unique bounded firmware section')
f = firmware[0]; offset = kernel.offset(f['addr'], f['size'])
data = blob[offset:offset+f['size']]
report = dict(kext=bundle, kernel_file_offset=offset,
firmware_size=len(data), sha256=hashlib.sha256(data).hexdigest())
return data, report


if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('kernel', type=Path)
parser.add_argument('output', type=Path)
parser.add_argument('--bundle', choices=(T8010, S8000), default=T8010)
args = parser.parse_args()
data, report = extract(args.kernel.read_bytes(), args.bundle)
# Never silently replace a firmware artifact.
with args.output.open('xb') as out:
out.write(data)
print(json.dumps(report, indent=2))
9 changes: 5 additions & 4 deletions src/orchard_cli/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import sys

from importlib.metadata import version
from orchard_cli.devices import load, choose
from orchard_cli.devices import SUPPORTED, load, choose, list_devices, profile_for_adt
from orchard_cli.progress import Progress
from orchard_cli.protocol import Client
from orchard_cli.releases import resolve, catalogue, register
Expand All @@ -36,7 +36,7 @@ def main():
distros.add_argument('source', nargs='?')
check = commands.add_parser('resolve', help='verify inputs without accessing a device')
check.add_argument('source')
check.add_argument('--profile', default='j71b')
check.add_argument('--profile', default='j71b', choices=SUPPORTED)
check.add_argument('--boot', action='store_true', help='resolve only boot assets')
commands.add_parser('devices', help='list USB devices without modifying them')
commands.add_parser('setup-host', help='install missing boot tools and Linux USB permissions; no device access')
Expand All @@ -51,6 +51,7 @@ def main():
mixin = commands.add_parser('mixin', help='prepare a data-only private mixin')
for name in ('adt', 'firmware-dir', 'output'):
mixin.add_argument('--' + name, type=Path, required=True)
mixin.add_argument('--profile', choices=SUPPORTED, help='device profile (default: selected from the ADT model)')
for action in ('status', 'diagnostics', 'targets', 'destination', 'prepare', 'install-root', 'handoff', 'abort'):
command = commands.add_parser(action, help='invoke the device-owned ' + action + ' operation')
command.add_argument('--recovery', action='store_true', help='provision access through the recovery serial shell')
Expand Down Expand Up @@ -90,7 +91,7 @@ def main():
record, assets = resolve(args.source, args.profile, progress, purpose='boot' if args.boot else 'install')
result = {'manifest': record, 'files': {key: str(path) for key, path in assets.items()}}
elif args.action == 'devices':
result = load().list_devices()
result = list_devices()
elif args.action == 'setup-host':
from orchard_cli.host_setup import setup
result = setup(progress)
Expand Down Expand Up @@ -124,7 +125,7 @@ def main():
stream.write(data)
result = {'saved': str(args.output), 'bytes': len(data)}
elif args.action == 'mixin':
adapter = load()
adapter = load(args.profile or profile_for_adt(args.adt.read_bytes()))
archive, result = adapter.prepare_mixin(args.adt, args.firmware_dir, args.output)
result = {**result, 'path': str(archive)}
else:
Expand Down
11 changes: 10 additions & 1 deletion src/orchard_cli/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ class MixinReceipt(TypedDict):
sha256: str


class UnsupportedDevice(ValueError):
"""discover() identified a connected target that this adapter does not support.

The registry tries every adapter; this refusal lets another adapter claim the
target instead of reporting an error. Other ValueErrors remain errors.
"""


@dataclass(frozen=True)
class Connection:
argv: List[str]
Expand All @@ -71,7 +79,8 @@ class Adapter(Protocol):
"""Required interface of a registered adapter module.

Discovery is read-only and raises ValueError when no unique supported device
is present. Boot must never retry uncertain mutations. connect(device) binds
is present; UnsupportedDevice when the target is another model. Boot must
never retry uncertain mutations. connect(device) binds
the selected ECID/boot; connect_saved(device) must never reprovision.
Exceptions propagate: the host owns confirmation/reporting, not recovery
recipes. There are no optional members or legacy fallbacks.
Expand Down
Loading