From a5b9ce705c4e436793bfdafd380d8188ee7980e1 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:10:17 -0600 Subject: [PATCH 1/3] Add P105 kernel producer and qualified hardware runtime Carry the P105 device recipe and runtime onto current main. Build public kernel components or an explicit private boot bundle from pinned sources, keep the flash controller disabled, and retain early mount points during private staging. Qualify the packaged private bundle against the preserved Arch desktop over USB NFS. Record the touch latency issue and the remaining CLI and desktop migration work. --- .gitattributes | 3 + BUILD.md | 6 + MANIFEST.in | 7 + NOTICE | 8 + README.md | 9 +- devices/p105/README.md | 130 +++++++ devices/p105/device.toml | 25 ++ devices/p105/firmware.json | 88 +++++ docs/p105-validation.md | 71 ++++ kernel/README.md | 5 + kernel/build | 3 + kernel/p105/Containerfile | 9 + kernel/p105/README.md | 282 +++++++++++++++ kernel/p105/build | 328 ++++++++++++++++++ kernel/p105/build-in-container.sh | 67 ++++ kernel/p105/config.fragment | 26 ++ kernel/p105/nand-disabled.patch | 12 + kernel/p105/native-container.patch | 15 + kernel/p105/prepare-source | 233 +++++++++++++ kernel/p105/private-calibration.patch | 39 +++ kernel/p105/upstream.lock | 5 + pyproject.toml | 6 + runtime/p105/README.md | 167 +++++++++ runtime/p105/install.py | 191 ++++++++++ .../NetworkManager/conf.d/90-p105-usb.conf | 3 + runtime/p105/overlay/etc/asound.conf | 26 ++ .../p105/overlay/etc/cascadia/systemd-root | 1 + .../logind.conf.d/50-p105-buttons.conf | 7 + .../50-p105-hardware.conf | 4 + .../bluetooth.service.d/50-p105-hardware.conf | 4 + .../usr/lib/cascadia/p105-payload.list | 8 + .../usr/lib/systemd/network/10-p105-usb.link | 6 + .../systemd/system/p105-acm-console.service | 21 ++ .../lib/systemd/system/p105-hardware.target | 10 + .../usr/lib/systemd/system/p105-keys.service | 15 + .../lib/systemd/system/p105-platform.service | 12 + .../usr/lib/systemd/system/p105-touch.service | 14 + .../lib/systemd/system/p105-wifi-chip.service | 15 + .../usr/lib/udev/rules.d/70-p105-home.rules | 2 + .../usr/lib/udev/rules.d/70-p105-touch.rules | 1 + .../p105/overlay/usr/libexec/p105/acm-rescue | 12 + .../overlay/usr/libexec/p105/check-platform | 17 + .../overlay/usr/libexec/p105/prepare-root | 65 ++++ .../overlay/usr/libexec/p105/systemd-init | 17 + .../p105/overlay/usr/libexec/p105/wifi-chip | 44 +++ .../patches/0001-p105-systemd-handoff.patch | 62 ++++ .../p105/patches/0002-p105-desktop-home.patch | 132 +++++++ tests/test_p105_components.py | 183 ++++++++++ tests/test_p105_data.py | 112 ++++++ tests/test_p105_prepare.py | 234 +++++++++++++ tests/test_p105_runtime.py | 225 ++++++++++++ 51 files changed, 2985 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 devices/p105/README.md create mode 100644 devices/p105/device.toml create mode 100644 devices/p105/firmware.json create mode 100644 docs/p105-validation.md create mode 100644 kernel/p105/Containerfile create mode 100644 kernel/p105/README.md create mode 100755 kernel/p105/build create mode 100755 kernel/p105/build-in-container.sh create mode 100644 kernel/p105/config.fragment create mode 100644 kernel/p105/nand-disabled.patch create mode 100644 kernel/p105/native-container.patch create mode 100755 kernel/p105/prepare-source create mode 100644 kernel/p105/private-calibration.patch create mode 100644 kernel/p105/upstream.lock create mode 100644 runtime/p105/README.md create mode 100755 runtime/p105/install.py create mode 100644 runtime/p105/overlay/etc/NetworkManager/conf.d/90-p105-usb.conf create mode 100644 runtime/p105/overlay/etc/asound.conf create mode 100644 runtime/p105/overlay/etc/cascadia/systemd-root create mode 100644 runtime/p105/overlay/etc/systemd/logind.conf.d/50-p105-buttons.conf create mode 100644 runtime/p105/overlay/etc/systemd/system/NetworkManager.service.d/50-p105-hardware.conf create mode 100644 runtime/p105/overlay/etc/systemd/system/bluetooth.service.d/50-p105-hardware.conf create mode 100644 runtime/p105/overlay/usr/lib/cascadia/p105-payload.list create mode 100644 runtime/p105/overlay/usr/lib/systemd/network/10-p105-usb.link create mode 100644 runtime/p105/overlay/usr/lib/systemd/system/p105-acm-console.service create mode 100644 runtime/p105/overlay/usr/lib/systemd/system/p105-hardware.target create mode 100644 runtime/p105/overlay/usr/lib/systemd/system/p105-keys.service create mode 100644 runtime/p105/overlay/usr/lib/systemd/system/p105-platform.service create mode 100644 runtime/p105/overlay/usr/lib/systemd/system/p105-touch.service create mode 100644 runtime/p105/overlay/usr/lib/systemd/system/p105-wifi-chip.service create mode 100644 runtime/p105/overlay/usr/lib/udev/rules.d/70-p105-home.rules create mode 100644 runtime/p105/overlay/usr/lib/udev/rules.d/70-p105-touch.rules create mode 100755 runtime/p105/overlay/usr/libexec/p105/acm-rescue create mode 100755 runtime/p105/overlay/usr/libexec/p105/check-platform create mode 100755 runtime/p105/overlay/usr/libexec/p105/prepare-root create mode 100755 runtime/p105/overlay/usr/libexec/p105/systemd-init create mode 100755 runtime/p105/overlay/usr/libexec/p105/wifi-chip create mode 100644 runtime/p105/patches/0001-p105-systemd-handoff.patch create mode 100644 runtime/p105/patches/0002-p105-desktop-home.patch create mode 100644 tests/test_p105_components.py create mode 100644 tests/test_p105_data.py create mode 100644 tests/test_p105_prepare.py create mode 100644 tests/test_p105_runtime.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dad7ec55 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Unified-diff context prefixes are part of the patch data. +kernel/p105/*.patch whitespace=-blank-at-eol,-space-before-tab +runtime/p105/patches/*.patch whitespace=-blank-at-eol,-space-before-tab diff --git a/BUILD.md b/BUILD.md index e02264f7..10d20408 100644 --- a/BUILD.md +++ b/BUILD.md @@ -30,6 +30,12 @@ Choose new output paths. Producers use pinned upstream revisions, hardware patch The m1n1 backend is unbranded by default. A distro can pass `--extra-patch FILE`, `--include-header FILE`, and `--extra-cflags FLAGS` for its own presentation. +## iPad mini 1 components + +The [P105 hardware profile](devices/p105/README.md) covers the first-generation Wi-Fi iPad mini. The [P105 producer](kernel/p105/README.md) builds ARMv7 public kernel components with an empty initramfs, or a private boot bundle from an explicit owner root and calibration. Run `kernel/build p105 --work NEW_WORK --output NEW_OUTPUT`; both directories must be outside this package. Its desktop configuration and device tree disable the reference NAND controller. + +The manual boot route uses an existing jailbreak and a root served over USB/NFS by the host. Follow [systemd runtime integration](runtime/p105/README.md) for the installed root. A private bundle embeds per-user recovery inputs and stays on the owner's host. The J71b release workflow continues to build J71b artifacts; P105 launcher publication and CLI integration are separate work. + ## Runtime and image assembly - `image/runtime.py`: modules, hardware/touch scripts, device API and integrity lists. diff --git a/MANIFEST.in b/MANIFEST.in index 4e4f205a..0647061b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,3 +9,10 @@ recursive-include .github/workflows *.yml prune boot/m1n1/.work prune boot/quiet/.work global-exclude __pycache__ *.py[cod] .DS_Store + +recursive-include devices/p105 *.md *.toml *.json +recursive-include kernel/p105 *.md *.lock *.fragment *.patch *.sh Containerfile prepare-source build +recursive-include runtime/p105 *.py *.md *.patch +recursive-include runtime/p105/overlay * +prune runtime/p105/build +prune kernel/p105/.work diff --git a/NOTICE b/NOTICE index c5592915..dca1a96d 100644 --- a/NOTICE +++ b/NOTICE @@ -14,3 +14,11 @@ selective reuse and adaptation, not a replacement of the pinned base kernel. m1n1: Asahi Linux Contributors, MIT (LICENSES/m1n1.txt). Wireless regulatory database: LICENSES/wireless-regdb.txt. + +P105/A5 manual bring-up uses Project Cascadia by rzxvx and its contributors: +https://github.com/rzxvx/Project-Cascadia +Pinned revision: c20b85ebb951c704d7e3a664c378dde8276e3928. +Cascadia carries the GNU GPL version 2. The source-preparation recipe fetches +that revision separately; its hardware timing remains in Cascadia. P105 +patches preserve the upstream source notices. Apple firmware and per-device +touch calibration are supplied privately by the device owner. diff --git a/README.md b/README.md index f4f67f77..0972ffc1 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,14 @@ Hardware support and image-building resources for Linux on Apple devices. This repository owns device descriptions, kernel patches/configuration, firmware recipes, recovery tooling and the device runtime. It does not depend on a desktop distribution or the host CLI. -## Supported hardware +## Hardware profiles -Currently: iPad 6 Wi-Fi (`iPad7,5`, J71b), experimental. Boot is tethered and GPU acceleration is not available. +| Device | Profile | Boot status | +| --- | --- | --- | +| iPad 6 Wi-Fi (`iPad7,5`) | J71b | Experimental tethered Orchard launcher; software rendering | +| iPad mini 1 Wi-Fi (`iPad2,5`, P105AP) | P105 | Experimental manual bring-up through an existing jailbreak, with a host-served NFS root | + +[P105 prerequisites and measured results](devices/p105/README.md) describe the tested A5/ARMv7 path. Its packaged runtime and pinned Cascadia preparation recipe support manual image assembly. Orchard CLI adapter integration and published launcher assets are separate work. ## Use diff --git a/devices/p105/README.md b/devices/p105/README.md new file mode 100644 index 00000000..c1109542 --- /dev/null +++ b/devices/p105/README.md @@ -0,0 +1,130 @@ +# iPad mini 1 Wi-Fi (P105) + +Experimental hardware prerequisites and component builds for the original +iPad mini Wi-Fi. The public resources describe the board and its firmware +inputs; the local boot path uses Cascadia and Legacy iOS Kit. An Orchard CLI +adapter and published P105 launch manifest remain pending. + +| Identity | Value | +| --- | --- | +| Apple product | iPad2,5 | +| Board / device tree | P105AP / apple,p105ap | +| SoC / DFU chip | Apple A5 S5L8942X / 0x8942 | +| DFU board | 0x0a | +| Userspace ABI | 32-bit ARMv7, hard float | +| Physical RAM | 512 MiB | +| Panel | 768 x 1024 | + +The Linux CDC gadget product string is generic. The descriptor records DFU +identity and device-tree compatibility and leaves Linux product matching +unset. The Wi-Fi, USB serial and USB network capability flags describe +observed hardware behavior. Provisioning still needs runtime confirmation. + +## Manual boot prerequisites + +- An iPad2,5 with a working jailbreak. The qualified device ran iOS 9.3.5 + (13G36), with an untethered jailbreak and OpenSSH installed through Cydia. +- A Lightning USB connection, a trusted computer and access to the owner's + iOS SSH credentials. The jailbreak supplies the kDFU entry path. +- A macOS or Linux host with the dependencies reported by Cascadia's doctor, + a working Docker-compatible build environment, and Legacy iOS Kit's host + USB tools. The local qualification used a Mac. +- The Apple iOS 8.4.1 / 12H321 IPSW pinned in firmware.json and the owner's + own multitouch calibration. The running iOS version and firmware-source + archive serve different steps of the boot path. +- A 32-bit ARMv7 root filesystem and a compatible kernel/runtime. The + qualified desktop used an external NFS root through USB; installed-root + policy belongs to its image builder. + +The component producer and source preparation entry points are documented in +[the P105 kernel recipe](../../kernel/p105/README.md): + +~~~sh +python3 kernel/p105/prepare-source --output ../cascadia-p105 +cd ../cascadia-p105 +./cascadia doctor +~~~ + +These commands prepare source outside the hardware checkout and check the +build host. Follow the kernel recipe for its pinned Linux commit checkout, +private firmware inputs and RAM bundle build. The source preparer applies +the P105 kernel fragment and runtime patches from this repository. +Cascadia's `flash --kdfu` selects the paired plaintext iBEC generated from +the pinned IPSW. See [the runtime handoff](../../runtime/p105/README.md) when +preparing an installed system; its installer requires an explicit owner +calibration file alongside the verified initramfs tree. + +The earlier physical qualification used upstream revisions +[Cascadia c20b85e](https://github.com/rzxvx/Project-Cascadia/tree/c20b85ebb951c704d7e3a664c378dde8276e3928) +and [Legacy iOS Kit 23622f8](https://github.com/LukeZGD/Legacy-iOS-Kit/tree/23622f8fe72c09952089d5145a7b008cfcc90d9b). +Cascadia's [quick start](https://github.com/rzxvx/Project-Cascadia/blob/c20b85ebb951c704d7e3a664c378dde8276e3928/docs/QUICKSTART.md) +describes the host setup and jailbreak-assisted USB boot sequence. + +## Firmware and private calibration + +firmware.json retains the shared device, IPSW, extracted-input and destination +fields used by the existing J71b recipe. Its preparation mode is explicitly +manual and source-only. The encrypted root filesystem needs Cascadia's pinned +extractor, and generated entries name Cascadia source steps. An Orchard +firmware-preparation adapter has yet to implement those steps. + +P105.mtprops is generic Apple touchscreen firmware extracted from the IPSW. +The separate mtcal.bin comes from the owner's panel calibration in syscfg +MtCl. The pinned upstream source contains another device's reference +calibration; source preparation excludes it and the runtime requires the +owner's file. Keep calibration bytes and their hashes out of commits, +public manifests and release artifacts. + +The generic output hashes cover the pinned archive and source generators. +The Wi-Fi image hash uses Cascadia's public synthetic MAC default. A custom +MAC produces a different image and needs a host-local receipt. The recipe +also records the Broadcom Bluetooth patchram and SGX microkernel required +by the qualified runtime. The BCM4334 build rejects the CLM-loading path, +so the recorded recipe supplies its combined TRX/NVRAM image. + +IPSW downloads, decrypted firmware, patched Apple bootloaders and device +calibration stay on the owner's host. This directory contains metadata and +instructions. + +## Current component qualification + +The packaged producer's private bundle booted this device on 27 October 2026 +and reached the Arch / Omarchy A desktop through the preserved USB/NFS root. +System and user managers reported zero failed units. The hardware payload +checksums passed, while the flash controller stayed disabled with an empty +MTD inventory. The owner calculated 2 + 2 = 4 in Omacalc and opened the app +grid with Home. + +Touch responds slowly and sometimes needs repeated taps, matching the owner's +experience with the earlier desktop. This remains an open issue. See the +[component validation record](../../docs/p105-validation.md) for the tested +scope and the package checks. + +## Earlier physical qualification + +The results below describe the original manual build and its desktop root. + +On an original iPad2,5 with an A5 and 512 MiB RAM, a native Arch Linux ARM +armv7h desktop ran on Cascadia Linux 6.12 with a USB/NFS root. A repeat boot +reached graphical.target in 27.478 seconds. Both system and user managers +reported zero failed units with the document portal running after FUSE was +built in. Display qualification used the native panel with Pixman and Qt +software rendering. + +The user confirmed touch input by calculating 2 + 2 = 4 in Omacalc. Foot and +Firefox launched on the iPad. Physical Home opened the application grid with +the runtime's opt-in --desktop-home forwarder. Wi-Fi association succeeded +through the on-iPad password UI. USB networking carried NFS and SSH, and the USB serial rescue +console remained available. + +The desktop runtime used systemd namespaces and cgroups, built-in FUSE, +uinput and a 256 MiB LZ4 zram device. Those requirements are recorded in the +kernel and runtime resources linked above. A user-supplied root must provide +its own session, service and package policy. + +Bluetooth pairing and audio playback remain untested. Accelerated graphics +still need qualification. Linux reboot currently powers the device off; +starting Linux again requires the host boot flow. Tethered boot and the +external root remain part of this qualified setup. P105 registration and +manual component preparation cover this contribution; release integration +and the Orchard CLI adapter remain separate work. diff --git a/devices/p105/device.toml b/devices/p105/device.toml new file mode 100644 index 00000000..08274f6c --- /dev/null +++ b/devices/p105/device.toml @@ -0,0 +1,25 @@ +# iPad mini 1 Wi-Fi (P105): identity and manual boot prerequisites. +schema = 1 +kind = "hardware.device" + +[device] +profile = "p105" +name = "iPad mini 1 (Wi-Fi)" +apple_products = ["iPad2,5"] +dt_compatible = ["apple,p105ap"] + +[match.dfu] +chip = 0x8942 +board = 0x0a + +[boot] +methods = ["jailbreak-kdfu"] +# The Orchard CLI adapter and published launch manifest remain pending. + +[firmware] +recipe = "firmware.json" + +[support] +wifi = true # Association was verified through the native desktop password UI. +usb_serial = true +usb_network = true diff --git a/devices/p105/firmware.json b/devices/p105/firmware.json new file mode 100644 index 00000000..14a895cc --- /dev/null +++ b/devices/p105/firmware.json @@ -0,0 +1,88 @@ +{ + "device": "iPad2,5", + "version": "8.4.1", + "build": "12H321", + "ipsw_url": "https://secure-appldnld.apple.com/ios8.4.1/031-31249-20150812-75235CC6-3C8F-11E5-848D-BE1A3A53DB92/iPad2,5_8.4.1_12H321_Restore.ipsw", + "ipsw_bytes": 1509764204, + "ipsw_sha256": "ce6cd59a4a3271eccb93b31c8d40e94ad966112999576663b3ab235196154756", + "preparation": { + "mode": "manual-source-only", + "source_url": "https://github.com/rzxvx/Project-Cascadia.git", + "source_commit": "c20b85ebb951c704d7e3a664c378dde8276e3928", + "entrypoint": "scripts/build-firmware.sh", + "rootfs_extractor": "scripts/rootfs-extract.py", + "rootfs_member": "058-24036-023.dmg", + "orchard_adapter": false, + "note": "The encrypted root filesystem and source generators require the pinned Cascadia workflow. Extraction patterns alone cannot prepare this recipe." + }, + "extract_patterns": [ + "^058-24036-023[.]dmg$", + "^kernelcache[.]release[.]p105$", + "^Firmware/dfu/iBSS[.]p105[.]RELEASE[.]dfu$", + "^Firmware/dfu/iBEC[.]p105[.]RELEASE[.]dfu$" + ], + "extracted_inputs": [ + { + "name": "P105.mtprops", + "source_path": "usr/share/firmware/multitouch/P105.mtprops", + "sha256": "516b8db5f6144ad64698867b8f6e6209a49010fcff1cb103539fe5ea4ffb1390" + }, + { + "name": "borg.trx", + "source_path": "usr/share/firmware/wifi/4334b1/borg.trx", + "sha256": "fcc6e5b6f085b7b1ae8558fe0af4a689ab9e7eb49689ef443e194865b0be7d12" + }, + { + "name": "borg-t-st.txt", + "source_path": "usr/share/firmware/wifi/4334b1/borg-t-st.txt", + "sha256": "78d24471b6fecaf63ebda5d4e055d976905cda8230b2e166c3c2080f05aa6d81" + } + ], + "files": [ + { + "name": "P105.mtprops", + "sha256": "516b8db5f6144ad64698867b8f6e6209a49010fcff1cb103539fe5ea4ffb1390", + "destination": "lib/firmware/P105.mtprops" + } + ], + "generated": [ + { + "name": "brcmfmac4334.bin", + "destination": "lib/firmware/brcm/brcmfmac4334.bin", + "sha256": "c02d3308434b7e2251bb2e235362e31a6033a4bc909c030888e8f9085afec5f4", + "generator": "cascadia-trx-add-nvram", + "source_script": "scripts/trx-add-nvram.py", + "inputs": ["borg.trx", "borg-t-st.txt"], + "configuration": "Pinned upstream WIFI_MAC synthetic default. A MAC override requires a separate host-local output receipt." + }, + { + "name": "BCM4334B0.hcd", + "destination": "lib/firmware/brcm/BCM4334B0.hcd", + "sha256": "b6791a67141f1a16447e916237180a049b89dfaab9c38451b33864dbb6a50dda", + "generator": "cascadia-bluetool-borg-tdk", + "source_script": "scripts/extract-bt-firmware.py", + "source_path": "usr/sbin/BlueTool", + "selection": "Borg TDK / BCM4334B1 37.4 MHz" + }, + { + "name": "sgx543.fw", + "destination": "lib/firmware/apple/sgx543.fw", + "sha256": "5f929f351459b169ed91a68dce086dbc682b639c4a31846ef5b12837562e9a77", + "generator": "cascadia-sgx543-firmware", + "source_script": "scripts/extract-sgx-firmware.py", + "source_member": "kernelcache.release.p105" + } + ], + "private_inputs": [ + { + "name": "mtcal.bin", + "destination": "lib/firmware/mtcal.bin", + "source": "The owner's iPad syscfg MtCl, captured from its jailbroken iOS", + "required": true, + "required_for": ["multitouch", "qualified desktop runtime"], + "allow_reference_fallback": false, + "publish": false, + "note": "Supply the owner's calibration explicitly. The upstream reference calibration is excluded; keep calibration bytes and hashes out of public recipes and releases." + } + ] +} diff --git a/docs/p105-validation.md b/docs/p105-validation.md new file mode 100644 index 00000000..ba313223 --- /dev/null +++ b/docs/p105-validation.md @@ -0,0 +1,71 @@ +# P105 component validation + +The P105 contribution builds ARMv7 kernel components from pinned Cascadia and +Linux source. An explicit private initramfs selects a boot bundle containing +the owner's firmware. The public producer embeds an empty initramfs. + +## Host checks + +The complete hardware suite passes 98 tests on Python 3.9.6 and Python 3.14.6. +These include source export, matching owner calibration and ARM ABI checks. +The component tests reject a public output containing a private payload and +verify the exact layout of a private bundle. + +Private staging creates the empty early mount directories before archiving. +Directory entries and modes are included in the inventory, with regression +coverage for empty mount points and linked destinations. + +The optional NFS location is accepted only for private roots. Tests reject +relative locations and embedded line breaks before any build work starts. + +The wheel and source archive contain all 38 P105 resources with bytes matching +the checkout. A fresh environment installs the wheel without a sibling source +dependency. The installed runtime stages its payload into a disposable ARMv7 +guest root using real ARM executables and the new build's static tools. + +Private compilation from the installed wheel ran under Python audit hooks +that denied reads from both source checkouts. A separate probe confirmed the +hooks rejected reads from each tree. Compiler containers mounted only the +exported source and explicit build inputs, with networking disabled. + +The compiler image was built from the packaged Containerfile. Component +receipts record the image identity and tool versions. Keep private receipts +on the owner's host because they include hashes of private inputs. + +## Device qualification + +On 27 October 2026, the installed hardware package produced a private bundle +that booted an iPad2,5 from iOS 9.3.5 build 13G36 through its existing jailbreak +and the manual iBEC transport. The bundle handed off to the preserved Arch +Linux ARM / Omarchy A root through USB NFS. + +| Check | Observed result | +| --- | --- | +| Kernel and board | Linux 6.12.0, `apple,p105ap` | +| Root filesystem | NFS over USB | +| Desktop | `graphical.target` active; Labwc and the desktop shell running | +| Service health | Zero failed system units and zero failed user units | +| Hardware activation | Platform, touch and Home-forwarding services active | +| Installed payload | All eight hardware payload checksums passed | +| Physical input | Omacalc returned 4 for 2 + 2; Home opened the app grid | +| Internal flash | Controller disabled, empty MTD inventory, no bound flash device | + +Touch responds slowly and sometimes needs repeated taps. The owner observed +the same behavior in the earlier desktop and accepted it for this milestone. +Touch latency remains an open issue. + +The ordinary P105 configuration disables `CONFIG_MTD_APPLE_H2FMI` and marks +the flash-controller node disabled. The remainder of the reference MTD +configuration is retained to keep the change scoped to this device's driver. +The on-device inventory confirmed that the controller stayed unbound. + +The final source's private staging matched the tested bundle's input tree, +including empty directories and modes. Private receipts retain the component +hashes and the device checks on the owner's host. + +The earlier Wi-Fi, application and display measurements in the device profile +remain attributed to the original manual build. This test qualifies the new +hardware components with the preserved desktop root. The Orchard CLI adapter +and a desktop built entirely from the new checkouts await later phases. +Bluetooth pairing, audio playback and accelerated graphics need device tests. +The current boot still needs the host transport and its NFS export. diff --git a/kernel/README.md b/kernel/README.md index 729f02c2..324e30b8 100644 --- a/kernel/README.md +++ b/kernel/README.md @@ -1,5 +1,10 @@ # Kernel build +P105 has an ARMv7 [component producer](p105/README.md) with separate public +kernel and private embedded-root outputs. Use `kernel/build p105 --work +NEW_WORK --output NEW_OUTPUT`; its desktop build disables the reference NAND +controller. The ARM64 J71b producer is described below. + `build` fetches the pinned Linux revision, applies the selected target's patches and config, compiles the kernel and external modules, stages the modules, and runs the target's final-DTB recipe. Git and Podman are required. diff --git a/kernel/build b/kernel/build index ecad4cd5..c9f310c4 100755 --- a/kernel/build +++ b/kernel/build @@ -4,6 +4,9 @@ set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) device=${1:-} [[ $# -gt 0 ]] && shift +if [[ "$device" == p105 ]]; then + exec python3 "$here/p105/build" "$@" +fi dest= resume=false while (($#)); do diff --git a/kernel/p105/Containerfile b/kernel/p105/Containerfile new file mode 100644 index 00000000..9f3ba419 --- /dev/null +++ b/kernel/p105/Containerfile @@ -0,0 +1,9 @@ +FROM docker.io/library/ubuntu:22.04@sha256:0a9887931b0e38af2e3487ed39e29436b0535c4f3f52e57745baaf79d1898956 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential gcc-arm-linux-gnueabihf binutils-arm-linux-gnueabihf \ + libc6-dev-armhf-cross bc bison flex libssl-dev libelf-dev \ + device-tree-compiler python3 git cpio rsync kmod xz-utils \ + && rm -rf /var/lib/apt/lists/* +ENV ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- +WORKDIR /cascadia diff --git a/kernel/p105/README.md b/kernel/p105/README.md new file mode 100644 index 00000000..47b63469 --- /dev/null +++ b/kernel/p105/README.md @@ -0,0 +1,282 @@ +# P105 kernel source preparation + +These resources prepare Cascadia's ARM32 kernel and RAM boot environment for +the original iPad mini Wi-Fi (`iPad2,5` / A1432 / `p105ap`). The installed-system +handoff and Home-button patches live in [runtime/p105](../../runtime/p105/). +P105 uses Cascadia's `zImage` and staging loader. `kernel/build p105` produces +public kernel components or an explicitly private boot bundle. The Orchard +CLI adapter and published launch manifest remain separate work. + +## Source pins and scope + +[`upstream.lock`](upstream.lock) records: + +- Cascadia `c20b85ebb951c704d7e3a664c378dde8276e3928`. +- Linux `adc218676eef25575469234709c2d87185ca223a` (`v6.12`). + +[`prepare-source`](prepare-source) exports tracked Cascadia source files, +applies the two runtime patches, [`private-calibration.patch`](private-calibration.patch) +[`native-container.patch`](native-container.patch) and +[`nand-disabled.patch`](nand-disabled.patch), +then merges [`config.fragment`](config.fragment) into `config/p105ap.config`. +It replaces existing definitions of the selected symbols before appending the +fragment, because Cascadia rejects contradictory config entries. + +The prepared tree contains `orchard-p105-source.json` with the revisions, +public patch/config hashes and the excluded pathname. The helper performs no +USB operations and produces no boot image. The merged configuration disables +the P105 NAND driver, and the device tree disables the flash-controller node. Cascadia's +reference geometry belongs to another NAND capacity; the ordinary desktop +build excludes that driver and its raw NAND command tools. + +## Component producer + +Run from this checkout or use the same paths under the installed hardware +package. Python 3.9+, Git and a running Podman engine are required. Choose new +work and output directories outside the hardware package: + +```sh +python3 kernel/p105/build \ + --work /path/to/p105-public-work \ + --output /path/to/p105-public-components +``` + +`kernel/build p105` accepts the same options. The producer fetches both source +pins, exports their tracked source, and compiles in a fresh Linux volume with +network access disabled. Its Containerfile supplies an ARM cross compiler on +a digest-pinned Ubuntu base. Package versions can change when rebuilding the +compiler image; the receipt records its exact image identity and tool output. +An explicit `--toolchain-image IMAGE` can select an already prepared compiler. + +The public output contains `zImage`, `p105ap.dtb`, `kernel.config`, +`kernel.release`, compiler/binutils descriptions and `components.json`. +This kernel has an empty embedded initramfs and empty `CONFIG_EXTRA_FIRMWARE`. +It is a component for later composition. A bootable P105 image needs an +embedded owner root because the staging loader does not supply an external +initrd pointer. + +After preparing the owner's RAM filesystem through the manual steps below, +build the private image with explicit inputs: + +```sh +python3 kernel/p105/build \ + --work /path/to/p105-private-work \ + --output /path/to/p105-private-components \ + --private-initramfs /path/to/owner-initramfs \ + --calibration /path/to/owner-MtCl.bin \ + --nfs-root 10.55.0.1:/path/to/exported/armv7-root +``` + +Private input validation happens before fetching source or starting a build. +The calibration must be a separate file matching the selected initramfs. +Public firmware hashes must match `devices/p105/firmware.json`. The producer +overlays the packaged boot sources and rebuilds `z2-boot` and `p105-keys`. +It removes the reference NAND utilities from the desktop root. An explicit +initramfs avoids Cascadia's implicit search for host SSH public keys. + +The NFS option is private host configuration. It creates `/etc/nfsroot` in +the staged image. Omitting it retains any supplied root setting; an input +without that file boots Cascadia's RAM environment. Prepare the host export +and USB address before the installed-root handoff. + +Private output additionally contains `staging-bundle.bin`, +`staging-loader.bin` and `initramfs.tar`. The receipt sets `private: true` +and `publishable: false`; these files include the owner's firmware and access +material. Extract the returned initramfs into a private directory for the +runtime installer so the installed root receives this build's hardware tools. + +`--linux-bundle FILE` accepts a local Git object bundle whose HEAD matches the +pinned Linux commit. `--cascadia-source DIRECTORY` accepts a clean checkout at +the Cascadia pin. Both inputs are exported before compilation, so the build +volume has no source-checkout mount. `--jobs N` controls compiler concurrency. +Completed volumes are removed by default. `--keep-work` retains one for +inspection. After a failed build, `state.json` identifies that build's owned +volume; inspect its logs and remove that exact volume when finished. + +The producer checks the final config and compiled DTB, validates bundle +framing, and records component hashes before promoting a complete output. +Build success still requires a fresh tethered boot and physical touch check +before updating the device qualification claim. + +## Private inputs and outputs + +Cascadia's pinned source tracks a reference panel calibration at +`initramfs/lib/firmware/mtcal.bin`. Preparation excludes that pathname before +exporting file data. The reference is never read, hashed or copied by this +recipe. Both `./cascadia build` and the direct kernel script require the +owner's nonempty `build/keep/lib/firmware/mtcal.bin`; the kernel script also +checks that the embedded initramfs contains the same bytes. + +Capture the calibration from the owner's iPad, or supply a previous capture +from that same panel. A filename and matching bytes establish consistency; +the operator remains responsible for choosing the owner's capture. + +The final kernel embeds its initramfs. That includes Apple firmware, panel +calibration and SSH access material, including the Linux SSH host key. Keep +the prepared/build tree, container volumes, kernel, initramfs and staging +bundles outside this hardware checkout. Treat those build outputs as private. +They belong outside public releases, CI artifacts and source packages. + +## Requirements + +Source preparation requires Python 3.9+, Git and awk. The manual build below +uses a working Docker-compatible `docker` command and a fresh Linux volume for +the kernel work tree. On macOS the engine must have a running Linux VM. The +source and private inputs are copied into its native filesystem before kernel +compilation; a shared Mac directory is used only for inputs and final copies. + +Cascadia's pinned `Dockerfile` provides the cross compiler, binutils, `dtc`, +GNU build tools, Python crypto/disassembly libraries and firmware tools. +Host-side rootfs preparation also uses curl or wget, tar, cpio, gzip and +rsync. Allocate at least 12 GB for a fresh source/build tree and the IPSW. + +Qualification used a native Ubuntu 22.04 ARM64 build container with +`arm-linux-gnueabihf-gcc` 11.4.0 +(`Ubuntu 11.4.0-1ubuntu1~22.04.3`) and binutils 2.38. ARM32 target binaries +are cross-compiled or unpacked during this kernel/initramfs flow. ARM32 +emulation is a separate requirement for building target userspace software. + +The source revisions and patches are pinned here. Cascadia's Dockerfile +obtains distro packages and iBoot32Patcher sources at build time, so it does +not lock every compiler/firmware-tool dependency. Retain the container image +digest and tool versions with private build evidence when comparing outputs. + +## Prepare source + +Run from the hardware checkout. The destination must be new and outside both +this checkout and an optional input checkout. + +```sh +P105_WORK="$HOME/p105-private" +python3 kernel/p105/prepare-source --output "$P105_WORK/cascadia" +``` + +The helper fetches the pinned Cascadia commit into a temporary bare repository. +To use an existing clean checkout at that commit instead: + +```sh +python3 kernel/p105/prepare-source \ + --source /path/to/clean/Project-Cascadia \ + --output "$P105_WORK/cascadia" +``` + +Tracked source files must match the pin. Ignored build inputs from an existing +checkout are excluded from the export. The intentionally excluded reference +calibration is outside the clean-source check. + +The kernel is fetched by commit inside the Linux build volume below. This +avoids a tag lookup and keeps its work tree on a Linux filesystem. + +## Prepare the owner's RAM environment + +Obtain `iPad2,5_8.4.1_12H321_Restore.ipsw` from Apple's firmware service as +described in Cascadia's pinned `docs/QUICKSTART.md`. The IPSW supplies firmware +and boot components; the installed iOS version stays in place. + +Create an SSH key for the owner's host and pass its public half explicitly. +Run these commands in the prepared tree, with Docker available: + +```sh +cd "$P105_WORK/cascadia" +IPSW=/path/to/owner-inputs/iPad2,5_8.4.1_12H321_Restore.ipsw ./cascadia firmware +install -d build +install -m 0644 /path/to/owner-inputs/host-key.pub build/host-public-key.pub +PUBKEY="$PWD/build/host-public-key.pub" ./cascadia rootfs +``` + +For a jailbroken iPad with OpenSSH, Legacy iOS Kit and a Lightning data cable, +capture its calibration while iOS is running: + +```sh +LIK=/path/to/Legacy-iOS-Kit ./cascadia mtcal +``` + +This writes `build/keep/lib/firmware/mtcal.bin`. To use an existing owner +capture, place it at that private path before building: + +```sh +install -d build/keep/lib/firmware +install -m 0600 /path/to/owner-inputs/MtCl.bin build/keep/lib/firmware/mtcal.bin +``` + +For a RAM-only build, continue to the build section. An installed NFS system also +needs the marker and runtime described in +[`runtime/p105/README.md`](../../runtime/p105/README.md), an assembled userspace, +and an export prepared by its owner. Set the owner's NFS location in +`build/keep/etc/nfsroot` before the final build. That file is private host +configuration and is embedded in the resulting kernel. + +## Build and verify + +Choose a fresh volume name. Cascadia's preceding firmware/rootfs commands build +the standard compiler image from its pinned Dockerfile. The small native +container adapter lets `./cascadia build` use that image's tools directly, +without trying to start another Docker engine inside the container. Set +`CASCADIA_BUILD_IN_CONTAINER=1` only in this compiler environment. + +```sh +P105_VOLUME=p105-private-build +mkdir -p "$P105_WORK/artifacts" +chmod 700 "$P105_WORK/artifacts" +docker volume create "$P105_VOLUME" +docker run --rm \ + -v "$PWD:/input:ro" \ + -v "$P105_VOLUME:/cascadia" \ + -v "$P105_WORK/artifacts:/artifacts" \ + -e CASCADIA_BUILD_IN_CONTAINER=1 \ + -e PUBKEY=/cascadia/build/host-public-key.pub \ + -w /cascadia cascadia-build bash -euc ' + test -z "$(ls -A /cascadia)" || { echo "Choose a fresh build volume" >&2; exit 1; } + rsync -a --exclude /build/linux/ --exclude /output/ --exclude /logs/ /input/ /cascadia/ + chown -R 0:0 build/initramfs-root build/keep + mkdir -p build/linux + git -C build/linux init + git -C build/linux remote add origin https://github.com/torvalds/linux.git + git -C build/linux fetch --depth 1 origin adc218676eef25575469234709c2d87185ca223a + git -C build/linux checkout --detach FETCH_HEAD + test "$(git -C build/linux rev-parse HEAD)" = adc218676eef25575469234709c2d87185ca223a + ./cascadia build + for symbol in CONFIG_NAMESPACES CONFIG_USER_NS CONFIG_CGROUPS CONFIG_MEMCG \ + CONFIG_CGROUP_PIDS CONFIG_POSIX_MQUEUE CONFIG_ZRAM CONFIG_ZRAM_BACKEND_LZ4 \ + CONFIG_FUSE_FS; do + grep -q "^${symbol}=y$" build/linux/.config || exit 1 + done + grep -q "^CONFIG_ZRAM_DEF_COMP=\"lz4\"$" build/linux/.config + cp -a output/. /artifacts/ + cp build/out/kernel-config /artifacts/kernel-config + cp orchard-p105-source.json /artifacts/ + ' +``` + +Cascadia applies its Linux patches, checks critical kernel symbols and boot +stamps, embeds the current RAM filesystem, then produces +`output/zImage-dtb`, `output/staging-bundle.bin` and +`output/staging-loader.bin`. These are copied to the private `artifacts` +directory above; the work tree and logs remain in the private Linux volume. +Keep that volume and `build/out/kernel-config` with the owner's private build +evidence. The build's initramfs validation checks +its boot scripts, ACM console and pinned USB address. + +Use the completed volume's `/cascadia/build/initramfs-root` as the runtime +installer input, so it receives this build's static tools and Home-button +change. The host's initial RAM tree was an input to that compilation. + +Copy the two private boot payloads into the prepared host tree before using +Cascadia's host-side boot command: + +```sh +install -d output +install -m 0600 "$P105_WORK/artifacts/staging-bundle.bin" output/staging-bundle.bin +install -m 0600 "$P105_WORK/artifacts/staging-loader.bin" output/staging-loader.bin +``` + +Use Cascadia's manual boot instructions with the owner's existing jailbreak +or supported A5 exploit hardware. A jailbreak-based boot uses the plaintext +iBEC selected by `./cascadia flash --kdfu`. Cold restarts require the host boot +flow again. Hardware qualification includes another cold boot and the +installed runtime checks; source preparation alone qualifies neither. + +The manual host transport remains necessary until the P105 Orchard adapter +is implemented. These components have their own build receipt; published +Orchard release manifests and the authenticated device endpoint are later +contributions. diff --git a/kernel/p105/build b/kernel/p105/build new file mode 100755 index 00000000..b528abe9 --- /dev/null +++ b/kernel/p105/build @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Produce P105 public kernel components or an explicitly private boot bundle.""" +import argparse +import datetime +import hashlib +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import tarfile +import tempfile +import uuid + +HERE = Path(__file__).resolve().parent +HARDWARE = HERE.parents[1] +DEFAULT_IMAGE = 'localhost/orchard-hardware-p105-toolchain:jammy' + + +def module(name, path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + result = importlib.util.module_from_spec(spec) + loader.exec_module(result) + return result + + +def sha(path): + value = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + value.update(chunk) + return value.hexdigest() + + +def fresh(path): + path = path.expanduser().absolute() + if path.exists() or path.is_symlink(): + raise ValueError('Choose a fresh path: ' + str(path)) + resolved = path.resolve() + if resolved == HARDWARE or HARDWARE in resolved.parents: + raise ValueError('Keep component work and outputs outside the hardware package') + return resolved + + +def inventory(root): + result = {} + for path in sorted(root.rglob('*')): + name = path.relative_to(root).as_posix() + mode = path.lstat().st_mode + if stat.S_ISLNK(mode): + result[name] = {'link': os.readlink(path)} + elif stat.S_ISREG(mode): + result[name] = {'sha256': sha(path), 'bytes': path.stat().st_size, + 'mode': stat.S_IMODE(mode)} + elif stat.S_ISDIR(mode): + result[name] = {'directory': True, 'mode': stat.S_IMODE(mode)} + else: + raise ValueError('Special file in build input: ' + name) + return result + + +def archive_tree(root, output): + before = inventory(root) + with tarfile.open(output, 'w') as archive: + for path in sorted(root.rglob('*')): + item = archive.gettarinfo(str(path), arcname=path.relative_to(root).as_posix()) + item.uid = item.gid = 0 + item.uname = item.gname = 'root' + if item.isfile(): + with path.open('rb') as stream: + archive.addfile(item, stream) + elif item.isdir() or item.issym(): + archive.addfile(item) + else: + raise ValueError('Special file in build input') + if before != inventory(root): + raise ValueError('Build input changed while archiving') + return before + + +def local_target(root, name): + path = root / name + if not path.resolve().is_relative_to(root): + raise ValueError('Private input escapes the initramfs: ' + name) + return path + + +def check_private(root, calibration): + if root.is_symlink() or not root.is_dir(): + raise ValueError('Select an existing initramfs directory') + root = root.resolve() + if calibration.is_symlink() or not calibration.is_file() or calibration.stat().st_size == 0: + raise ValueError('Select the owner calibration as a nonempty regular file') + if calibration.resolve().is_relative_to(root): + raise ValueError('Supply the owner calibration separately from the initramfs') + embedded = local_target(root, 'lib/firmware/mtcal.bin') + if not embedded.is_file() or embedded.read_bytes() != calibration.read_bytes(): + raise ValueError('Initramfs calibration differs from the owner input') + installer = module('p105_build_installer', HARDWARE / 'runtime/p105/install.py') + installer.arm_elf(local_target(root, 'bin/busybox.static'), static=True) + for name in ('init', 'sbin/mount.nfs', 'usr/sbin/dropbear'): + if not local_target(root, name).is_file(): + raise ValueError('Missing private boot input: ' + name) + recipe = json.loads((HARDWARE / 'devices/p105/firmware.json').read_text()) + for entry in recipe['files'] + recipe['generated']: + path = local_target(root, entry['destination']) + if not path.is_file() or sha(path) != entry['sha256']: + raise ValueError('Private firmware differs from the pinned recipe: ' + entry['name']) + + +def overlay(source, destination): + """Copy public boot sources without following a guest's destination links.""" + for path in sorted(source.rglob('*')): + relative = path.relative_to(source) + target = destination / relative + for parent in (target, *target.parents): + if parent == destination: + break + if parent.is_symlink(): + raise ValueError('Linked overlay destination: ' + relative.as_posix()) + if path.is_symlink(): + raise ValueError('Linked public overlay source: ' + relative.as_posix()) + if path.is_dir(): + target.mkdir(parents=True, exist_ok=True) + elif path.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + else: + raise ValueError('Special public overlay source') + + +def nfs_location(value): + if value is not None and (len(value) > 4096 or not re.fullmatch( + r'(?:[A-Za-z0-9][A-Za-z0-9.-]*|\[[0-9A-Fa-f:]+\]):/[^\r\n\x00]+', value)): + raise ValueError('Expected --nfs-root HOST:/absolute/export/path') + return value + + +def stage_private(source, calibration, prepared, work, nfs_root=None): + check_private(source, calibration) + stage = work / 'private-root' + shutil.copytree(source, stage, symlinks=True) + boot_mounts(stage) + overlay(prepared / 'rootfs/alpine', stage) + overlay(prepared / 'initramfs', stage) + for path in stage.rglob('*'): + if path.name in ('nandctl', 'iosnand', 'ios') and (path.is_file() or path.is_symlink()): + path.unlink() + boot_date = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d 00:00:00\n') + local_target(stage, 'etc/build-date').write_text(boot_date) + if nfs_root is not None: + local_target(stage, 'etc/nfsroot').write_text(nfs_location(nfs_root) + '\n') + if local_target(stage, 'lib/firmware/mtcal.bin').read_bytes() != calibration.read_bytes(): + raise ValueError('Owner calibration changed during private staging') + return stage + + +def boot_mounts(root): + """Preserve the empty mount points needed before the first userspace mount.""" + for name in ('dev', 'proc', 'sys', 'run', 'tmp'): + path = root / name + if path.is_symlink() or (path.exists() and not path.is_dir()): + raise ValueError('Invalid early mount point: ' + name) + path.mkdir(exist_ok=True) + path.chmod(0o1777 if name == 'tmp' else 0o755) + + +def run(*args, **options): + return subprocess.run([str(arg) for arg in args], check=True, **options) + + +def linux_archive(work, lock, bundle=None): + repository = work / 'linux.git' + run('git', 'init', '--bare', '-q', repository) + if bundle: + # Unbundle imports the pinned tree without traversing unavailable + # parents when a local source cache came from a shallow checkout. + heads = subprocess.check_output(['git', 'bundle', 'list-heads', str(bundle.resolve()), 'HEAD'], text=True) + if heads.strip() != lock['linux_commit'] + ' HEAD': + raise ValueError('Linux bundle HEAD differs from upstream.lock') + run('git', '-C', repository, 'bundle', 'unbundle', bundle.resolve(), stdout=subprocess.DEVNULL) + reference = lock['linux_commit'] + else: + run('git', '-C', repository, 'fetch', '--depth', '1', lock['linux_url'], lock['linux_commit']) + reference = 'FETCH_HEAD' + actual = subprocess.check_output(['git', '-C', str(repository), 'rev-parse', reference + '^{commit}'], text=True).strip() + if actual != lock['linux_commit']: + raise ValueError('Linux source revision differs from upstream.lock') + run('git', '-C', repository, 'archive', '--format=tar', + '--output=' + str(work / 'inputs/linux.tar'), actual) + + +def validate_outputs(output, private): + files = {p.name for p in output.iterdir()} + expected = {'zImage', 'p105ap.dtb', 'kernel.config', 'kernel.release', 'compiler.txt', 'binutils.txt'} + if private: + expected |= {'staging-bundle.bin', 'staging-loader.bin', 'initramfs.tar'} + if files != expected or any(p.is_symlink() or not p.is_file() for p in output.iterdir()): + raise ValueError('Unexpected component output set') + data = (output / 'zImage').read_bytes() + if len(data) < 48 or data[36:40] != bytes.fromhex('18286f01'): + raise ValueError('Producer returned an invalid ARM zImage') + dtb = (output / 'p105ap.dtb').read_bytes() + if dtb[:4] != bytes.fromhex('d00dfeed') or int.from_bytes(dtb[4:8], 'big') != len(dtb): + raise ValueError('Producer returned an invalid device tree') + config = (output / 'kernel.config').read_text() + if any(line in config.splitlines() for line in ('CONFIG_MTD_APPLE_H2FMI=y', 'CONFIG_MTD_APPLE_H2FMI=m')): + raise ValueError('NAND controller was enabled in a desktop component') + if not private and 'CONFIG_INITRAMFS_SOURCE=""' not in config.splitlines(): + raise ValueError('Public kernel embeds an initramfs') + if private: + loader = (output / 'staging-loader.bin').read_bytes() + bundle = (output / 'staging-bundle.bin').read_bytes() + if not loader or len(loader) > 32768 or bundle != loader.ljust(32768, b'\0') + data + dtb: + raise ValueError('Private bundle differs from its component files') + + +def build(args): + work, output = fresh(args.work), fresh(args.output) + if work in output.parents or output in work.parents or work == output: + raise ValueError('Work and output directories must be separate') + private = args.private_initramfs is not None + if private != (args.calibration is not None): + raise ValueError('--private-initramfs and --calibration must be supplied together') + if not 1 <= args.jobs <= 64: + raise ValueError('Choose between 1 and 64 build jobs') + nfs_root = nfs_location(args.nfs_root) + if nfs_root is not None and not private: + raise ValueError('--nfs-root requires an explicitly private boot root') + if private: + check_private(args.private_initramfs, args.calibration) + # Validate private inputs before network, output creation or VM operations. + os.umask(0o077) + work.mkdir(parents=True, mode=0o700) + inputs = work / 'inputs' + inputs.mkdir() + prepare = module('p105_component_prepare', HERE / 'prepare-source') + prepared = work / 'cascadia' + prepare.prepare(argparse.Namespace(source=args.cascadia_source, output=prepared)) + lock = prepare.pins() + source = archive_tree(prepared, inputs / 'source.tar') + linux_archive(work, lock, args.linux_bundle) + private_receipt = None + if private: + stage = stage_private(args.private_initramfs, args.calibration, prepared, work, nfs_root) + private_receipt = archive_tree(stage, inputs / 'initramfs.tar') + shutil.copyfile(args.calibration, inputs / 'calibration') + shutil.copyfile(HERE / 'build-in-container.sh', inputs / 'build-in-container.sh') + inspect = subprocess.run(['podman', 'image', 'inspect', args.toolchain_image, + '--format', '{{.Id}}'], capture_output=True, text=True) + if inspect.returncode: + if args.toolchain_image != DEFAULT_IMAGE: + raise ValueError('Requested toolchain image is unavailable') + run('podman', 'build', '-f', HERE / 'Containerfile', '-t', DEFAULT_IMAGE, HERE) + image = subprocess.check_output(['podman', 'image', 'inspect', DEFAULT_IMAGE, + '--format', '{{.Id}}'], text=True).strip() + else: + image = inspect.stdout.strip() + if not re.fullmatch(r'(?:sha256:)?[0-9a-f]{64}', image): + raise ValueError('Invalid toolchain image identity') + volume = 'orchard-p105-' + uuid.uuid4().hex + run('podman', 'volume', 'create', '--label', 'io.orchard.profile=p105', volume, stdout=subprocess.DEVNULL) + state = {'schema': 1, 'mode': 'private' if private else 'public', 'volume': volume, + 'toolchain_image': image, 'completed': False} + (work / 'state.json').write_text(json.dumps(state, indent=2) + '\n') + output.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix='.' + output.name + '-', dir=output.parent)) + try: + log_path = work / 'build.log' + print('Compiling P105 components; build log: ' + str(log_path), flush=True) + with log_path.open('w') as log: + run('podman', 'run', '--rm', '--network', 'none', '--cap-drop', 'all', + '--security-opt', 'no-new-privileges', '-v', volume + ':/cascadia', + '-v', str(inputs) + ':/inputs:ro', '-v', str(temporary) + ':/export:rw', + '-w', '/cascadia', image, 'bash', '/inputs/build-in-container.sh', + state['mode'], str(args.jobs), stdout=log, stderr=subprocess.STDOUT) + validate_outputs(temporary, private) + receipt = {'schema': 1, 'kind': 'hardware.p105.components', 'profile': 'p105', + 'architecture': 'armv7h', 'private': private, 'publishable': not private, + 'flash_controller': 'disabled', 'upstream': lock, + 'prepared_source': source, 'toolchain_image': image, + 'recipe_sha256': sha(HERE / 'build-in-container.sh'), + 'producer_sha256': sha(HERE / 'build'), + 'components': inventory(temporary), + 'qualification': 'build-only; physical-device validation required'} + if private: + receipt['private_inputs'] = private_receipt + (temporary / 'components.json').write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') + if output.exists() or output.is_symlink(): + raise ValueError('Output appeared during the build; refusing to replace it') + temporary.rename(output) + state['completed'] = True + (work / 'state.json').write_text(json.dumps(state, indent=2) + '\n') + if not args.keep_work: + run('podman', 'volume', 'rm', volume, stdout=subprocess.DEVNULL) + print('P105 ' + state['mode'] + ' components: ' + str(output)) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--work', required=True, type=Path, help='Fresh work directory outside the hardware checkout') + parser.add_argument('--output', required=True, type=Path, help='Fresh component output outside the hardware checkout') + parser.add_argument('--private-initramfs', type=Path, help='Owner boot root; produces a private embedded bundle') + parser.add_argument('--calibration', type=Path, help='Owner calibration outside the supplied boot root') + parser.add_argument('--nfs-root', help='Optional private HOST:/absolute/export/path for the desktop root') + parser.add_argument('--cascadia-source', type=Path, help='Optional clean checkout at the pinned commit') + parser.add_argument('--linux-bundle', type=Path, help='Optional Git bundle whose HEAD is the pinned Linux commit') + parser.add_argument('--toolchain-image', default=DEFAULT_IMAGE) + parser.add_argument('--jobs', type=int, default=2) + parser.add_argument('--keep-work', action='store_true', help='Retain this build volume for inspection') + args = parser.parse_args() + try: + build(args) + except (OSError, ValueError, subprocess.CalledProcessError, tarfile.TarError) as error: + parser.exit(1, 'p105 components: ' + str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/kernel/p105/build-in-container.sh b/kernel/p105/build-in-container.sh new file mode 100755 index 00000000..64583e74 --- /dev/null +++ b/kernel/p105/build-in-container.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-only +set -euo pipefail +umask 077 +mode=$1 +jobs=$2 +[[ "$mode" == public || "$mode" == private ]] +[[ "$jobs" =~ ^[1-9][0-9]*$ ]] +test -z "$(ls -A /cascadia)" +tar -xf /inputs/source.tar -C /cascadia +mkdir -p build/linux build/out output logs +tar -xf /inputs/linux.tar -C build/linux +export ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- +export KBUILD_BUILD_USER=orchard KBUILD_BUILD_HOST=builder KBUILD_BUILD_VERSION=1 +export KBUILD_BUILD_TIMESTAMP='2026-10-27T00:00:00Z' LOCALVERSION='' +if [[ "$mode" == private ]]; then + mkdir -p build/initramfs-root build/keep/lib/firmware + tar -xf /inputs/initramfs.tar -C build/initramfs-root + cp /inputs/calibration build/keep/lib/firmware/mtcal.bin + for tool in z2-boot p105-keys; do + arm-linux-gnueabihf-gcc -static -Os -Wall -o "build/initramfs-root/sbin/$tool" "tools/$tool.c" + done + bash scripts/build-kernel.sh -j "$jobs" +else + # Public mode has no private input mount and embeds an empty initramfs. + export ORCHARD_P105_PUBLIC_KERNEL=1 INITRAMFS_DIR=/cascadia/build/no-initramfs + printf '\nCONFIG_INITRAMFS_SOURCE=""\n' >> config/p105ap.config + bash scripts/build-kernel.sh -j "$jobs" +fi +config=build/linux/.config +for symbol in CONFIG_NAMESPACES CONFIG_USER_NS CONFIG_CGROUPS CONFIG_MEMCG \ + CONFIG_CGROUP_PIDS CONFIG_POSIX_MQUEUE CONFIG_ZRAM CONFIG_ZRAM_BACKEND_LZ4 CONFIG_FUSE_FS; do + grep -qx "$symbol=y" "$config" +done +grep -qx 'CONFIG_ZRAM_DEF_COMP="lz4"' "$config" +if grep -Eq '^CONFIG_MTD_APPLE_H2FMI=[ym]$' "$config"; then + echo 'P105 desktop build must exclude the NAND controller' >&2 + exit 1 +fi +grep -qx 'CONFIG_EXTRA_FIRMWARE=""' "$config" +dtc -I dts -O dtb dts/p105ap.dts -o output/p105ap.dtb 2>logs/dtc.log +test "$(fdtget output/p105ap.dtb /soc@30000000/flash-controller@1200000 status)" = disabled +if [[ "$mode" == public ]]; then + grep -qx 'CONFIG_INITRAMFS_SOURCE=""' "$config" +else + cmp build/keep/lib/firmware/mtcal.bin build/initramfs-root/lib/firmware/mtcal.bin + for marker in 'P105: stage1 start' p105-systemd-v1 ttyGS0 10.55.0.2; do + LC_ALL=C grep -aFq "$marker" build/linux/usr/initramfs_data.cpio + done + python3 scripts/cpio-owners.py build/linux/usr/initramfs_data.cpio > output/cpio-owners.txt + test "$(head -n 1 output/cpio-owners.txt)" = '-' + cat build/out/zImage output/p105ap.dtb > output/zImage-dtb + bash build-bundle.sh > logs/bundle.log 2>&1 + cp output/linux-boot.bin output/staging-loader.bin + test "$(stat -c%s output/staging-bundle.bin)" = "$(( $(stat -c%s output/zImage-dtb) + 32768 ))" +fi +cp build/out/zImage /export/zImage +cp output/p105ap.dtb /export/p105ap.dtb +cp "$config" /export/kernel.config +cp build/linux/include/config/kernel.release /export/kernel.release +arm-linux-gnueabihf-gcc --version > /export/compiler.txt +arm-linux-gnueabihf-ld --version > /export/binutils.txt +if [[ "$mode" == private ]]; then + cp output/staging-bundle.bin output/staging-loader.bin /export/ + # The runtime installer must receive the tools from this build. + tar -cf /export/initramfs.tar -C build/initramfs-root . +fi diff --git a/kernel/p105/config.fragment b/kernel/p105/config.fragment new file mode 100644 index 00000000..ee0e124f --- /dev/null +++ b/kernel/p105/config.fragment @@ -0,0 +1,26 @@ +# P105 installed userspace: systemd isolation, compressed RAM swap and FUSE. +CONFIG_NAMESPACES=y +CONFIG_UTS_NS=y +CONFIG_IPC_NS=y +CONFIG_USER_NS=y +CONFIG_PID_NS=y +CONFIG_NET_NS=y +CONFIG_POSIX_MQUEUE=y +CONFIG_MEMCG=y +CONFIG_CGROUP_PIDS=y +CONFIG_CGROUP_FREEZER=y +CONFIG_CGROUP_DEVICE=y +CONFIG_CGROUP_CPUACCT=y +CONFIG_CGROUP_SCHED=y +CONFIG_FAIR_GROUP_SCHED=y +CONFIG_CFS_BANDWIDTH=y +CONFIG_CPUSETS=y +CONFIG_BPF_SYSCALL=y +CONFIG_CGROUP_BPF=y +CONFIG_ZRAM=y +CONFIG_ZRAM_BACKEND_LZ4=y +CONFIG_ZRAM_DEF_COMP_LZ4=y +CONFIG_FUSE_FS=y + +# The reference NAND geometry is unqualified on other P105 capacities. +# CONFIG_MTD_APPLE_H2FMI is not set diff --git a/kernel/p105/nand-disabled.patch b/kernel/p105/nand-disabled.patch new file mode 100644 index 00000000..7a50a794 --- /dev/null +++ b/kernel/p105/nand-disabled.patch @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: GPL-2.0-only +--- a/dts/p105ap.dts ++++ b/dts/p105ap.dts +@@ -702,7 +702,7 @@ + <0x1340000 0x1000>, /* FMC1 */ + <0x1380000 0x1000>; /* ECC1 */ + interrupts = <33>, <34>; +- status = "okay"; ++ status = "disabled"; + }; + + ohci1: usb@6600000 { diff --git a/kernel/p105/native-container.patch b/kernel/p105/native-container.patch new file mode 100644 index 00000000..44b92318 --- /dev/null +++ b/kernel/p105/native-container.patch @@ -0,0 +1,15 @@ +diff --git a/cascadia b/cascadia +--- a/cascadia ++++ b/cascadia +@@ -93,6 +93,11 @@ + } + + in_docker() { ++ # Local build mode: this process already runs in the compiler image. ++ if [ "${CASCADIA_BUILD_IN_CONTAINER:-0}" = 1 ]; then ++ bash -c "$1" ++ return ++ fi + ensure_image + # Run as the calling user on Linux. Docker Desktop on macOS maps ownership + # for you; a plain Linux daemon does not, so everything the build writes diff --git a/kernel/p105/prepare-source b/kernel/p105/prepare-source new file mode 100755 index 00000000..087f00d7 --- /dev/null +++ b/kernel/p105/prepare-source @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Prepare pinned Cascadia sources for a private P105 build; no device access.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import tarfile +import tempfile + +HERE = Path(__file__).resolve().parent +HARDWARE = HERE.parents[1] +EXCLUDED = ('initramfs/lib/firmware/mtcal.bin',) +RUNTIME_PATCHES = ( + 'runtime/p105/patches/0001-p105-systemd-handoff.patch', + 'runtime/p105/patches/0002-p105-desktop-home.patch', +) +SYMBOL = re.compile(r'^(?:# )?(CONFIG_[A-Z0-9_]+)(?:=| is not set$)') + + +def checked(command, **options): + return subprocess.run(command, check=True, **options) + + +def git_output(repository, *arguments): + return subprocess.check_output(['git', '-C', str(repository), *arguments]) + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def check_source_files(repository, commit, paths): + # Compare only the selected public files. A general Git index refresh can + # inspect other working-tree files, including the excluded calibration. + records = git_output(repository, 'ls-tree', '-r', '-z', commit, '--', *paths) + for record in records.split(b'\0'): + if not record: + continue + metadata, filename = record.split(b'\t', 1) + mode, kind, expected = metadata.decode().split() + name = filename.decode() + if name in EXCLUDED or kind != 'blob': + raise ValueError('Unexpected selected source entry: ' + name) + path = repository / name + if mode == '120000': + if not path.is_symlink(): + raise ValueError('--source has a modified source link: ' + name) + data = os.fsencode(os.readlink(path)) + else: + if path.is_symlink() or not path.is_file(): + raise ValueError('--source has a missing source file: ' + name) + if bool(path.stat().st_mode & 0o111) != (mode == '100755'): + raise ValueError('--source has modified source modes: ' + name) + data = path.read_bytes() + actual = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() + if actual != expected: + raise ValueError('--source has modified tracked source files: ' + name) + staged = subprocess.run(['git', '-C', str(repository), 'diff', '--cached', '--quiet', commit, '--', *paths]) + if staged.returncode: + raise ValueError('--source has staged source changes') + + +def pins(): + values = {} + for line in (HERE / 'upstream.lock').read_text().splitlines(): + if line and not line.startswith('#'): + key, value = line.split('=', 1) + if key in values: + raise ValueError('Duplicate upstream pin: ' + key) + values[key] = value + if (values.get('cascadia_url') != 'https://github.com/rzxvx/Project-Cascadia.git' + or values.get('linux_url') != 'https://github.com/torvalds/linux.git' + or values.get('linux_tag') != 'v6.12' + or any(not re.fullmatch(r'[0-9a-f]{40}', values.get(key, '')) + for key in ('cascadia_commit', 'linux_commit'))): + raise ValueError('Unexpected P105 upstream.lock') + return values + + +def merge_config(base, fragment): + symbols = {match.group(1) for line in fragment.splitlines() + if (match := SYMBOL.match(line))} + if not symbols or 'CONFIG_INITRAMFS_SOURCE' in symbols: + raise ValueError('The public fragment must omit per-user initramfs paths') + retained = [] + begin = '# Orchard P105 userspace prerequisites.' + end = '# End Orchard P105 userspace prerequisites.' + managed = False + for line in base.splitlines(): + if line == begin: + if managed: + raise ValueError('Nested P105 config block') + managed = True + continue + if managed: + if line == end: + managed = False + continue + match = SYMBOL.match(line) + if not match or match.group(1) not in symbols: + retained.append(line) + if managed: + raise ValueError('Unterminated P105 config block') + return '\n'.join(retained).rstrip() + '\n\n' + begin + '\n' + fragment.rstrip() + '\n' + end + '\n' + + +def export_source(repository, commit, paths, destination, archive_path): + # Naming individual tracked files excludes the reference calibration before + # git archive reads any file data. No working-tree/private inputs are copied. + if sum(len(path.encode()) + 1 for path in paths) > 120000: + raise ValueError('Pinned source path list exceeds the supported argument budget') + checked(['git', '-C', str(repository), 'archive', '--format=tar', + '--output=' + str(archive_path), commit, '--', *paths]) + with tarfile.open(archive_path, 'r:') as archive: + for member in archive: + name = PurePosixPath(member.name) + if name.is_absolute() or '..' in name.parts or str(name) in EXCLUDED: + raise ValueError('Unexpected exported source path: ' + member.name) + target = destination.joinpath(*name.parts) + for parent in target.parents: + if parent == destination: + break + if parent.is_symlink(): + raise ValueError('Exported path is below a symlink: ' + member.name) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + target.parent.mkdir(parents=True, exist_ok=True) + with archive.extractfile(member) as source, target.open('wb') as output: + shutil.copyfileobj(source, output) + target.chmod(member.mode & 0o777) + elif member.issym(): + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(member.linkname) + else: + raise ValueError('Unexpected source archive entry: ' + member.name) + + +def prepare(args): + lock = pins() + requested_output = args.output.expanduser().absolute() + if requested_output.exists() or requested_output.is_symlink(): + raise ValueError('Choose a fresh output directory') + output = requested_output.resolve() + if output == HARDWARE or HARDWARE in output.parents: + raise ValueError('Choose an output outside the hardware checkout; later build products are private') + if shutil.which('git') is None: + raise ValueError('Git is required') + patch_paths = [HARDWARE / name for name in RUNTIME_PATCHES] + [ + HERE / 'private-calibration.patch', HERE / 'native-container.patch', + HERE / 'nand-disabled.patch'] + if any(not path.is_file() for path in patch_paths): + raise ValueError('The P105 runtime and private-calibration patches must be present') + with tempfile.TemporaryDirectory(prefix='orchard-p105-source-') as temporary: + temporary = Path(temporary) + if args.source: + repository = args.source.expanduser().resolve() + top = Path(git_output(repository, 'rev-parse', '--show-toplevel').decode().strip()).resolve() + if top != repository: + raise ValueError('--source must name the Cascadia checkout root') + actual = git_output(repository, 'rev-parse', '--verify', 'HEAD^{commit}').decode().strip() + else: + repository = temporary / 'upstream.git' + checked(['git', 'init', '--bare', '-q', str(repository)]) + checked(['git', '-C', str(repository), 'fetch', '--progress', '--depth', '1', + lock['cascadia_url'], lock['cascadia_commit']]) + actual = git_output(repository, 'rev-parse', '--verify', 'FETCH_HEAD^{commit}').decode().strip() + if actual != lock['cascadia_commit']: + raise ValueError('Cascadia revision differs from upstream.lock') + names = git_output(repository, 'ls-tree', '-r', '-z', '--name-only', actual).decode().split('\0') + paths = [name for name in names if name and name not in EXCLUDED] + if args.source: + check_source_files(repository, actual, paths) + others = git_output(repository, 'ls-files', '--others', '--exclude-standard', '-z') + if others: + raise ValueError('--source must have no untracked non-ignored files') + if output == repository or repository in output.parents: + raise ValueError('Output must be outside the input checkout') + tree = temporary / 'prepared' + tree.mkdir() + export_source(repository, actual, paths, tree, temporary / 'source.tar') + for path in patch_paths: + checked(['git', '-C', str(tree), 'apply', '--check', str(path)]) + checked(['git', '-C', str(tree), 'apply', str(path)]) + config = tree / 'config/p105ap.config' + config.write_text(merge_config(config.read_text(), (HERE / 'config.fragment').read_text())) + checked(['awk', '-f', str(tree / 'scripts/check-config-fragment.awk'), str(config)]) + if any((tree / path).exists() for path in EXCLUDED): + raise ValueError('Excluded reference calibration appeared in the prepared source') + receipt = { + 'schema': 1, 'kind': 'hardware.p105.prepared-source', 'upstream': lock, + 'excluded_tracked_paths': list(EXCLUDED), + 'patches': [{'path': path.relative_to(HARDWARE).as_posix(), 'sha256': sha(path)} + for path in patch_paths], + 'config_fragment_sha256': sha(HERE / 'config.fragment'), + 'merged_config_sha256': sha(config), + 'private_build_outputs': True, + } + (tree / 'orchard-p105-source.json').write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') + output.parent.mkdir(parents=True, exist_ok=True) + # Copy across filesystems, then promote only a complete prepared tree. + stage = Path(tempfile.mkdtemp(prefix='.' + output.name + '-', dir=output.parent)) + try: + shutil.copytree(tree, stage, symlinks=True, dirs_exist_ok=True) + if output.exists() or output.is_symlink(): + raise ValueError('Output appeared during preparation; refusing to replace it') + os.rename(stage, output) + finally: + if stage.exists(): + shutil.rmtree(stage) + print('Prepared pinned P105 sources: ' + str(output)) + print('Private boot builds require the owner panel calibration; public kernels omit the initramfs.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, help='Clean checkout at the pinned Cascadia commit; otherwise fetch it') + parser.add_argument('--output', type=Path, required=True, help='New directory outside this hardware checkout') + args = parser.parse_args() + try: + prepare(args) + except (OSError, ValueError, subprocess.CalledProcessError, tarfile.TarError) as error: + parser.exit(1, 'p105 source preparation: ' + str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/kernel/p105/private-calibration.patch b/kernel/p105/private-calibration.patch new file mode 100644 index 00000000..c6eb5665 --- /dev/null +++ b/kernel/p105/private-calibration.patch @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: GPL-2.0-only +--- a/cascadia ++++ b/cascadia +@@ -220,6 +220,7 @@ + } + + cmd_build() { ++ [ -s "$ROOT/build/keep/lib/firmware/mtcal.bin" ] || fail "owner panel calibration required: supply build/keep/lib/firmware/mtcal.bin before building" + [ -f "$ROOT/build/linux/Makefile" ] || fail "no kernel source -- run: ./cascadia kernel" + [ -d "$ROOT/build/initramfs-root" ] || fail "no rootfs -- run: ./cascadia rootfs" + mkdir -p "$ROOT/build/out" "$ROOT/output" +--- a/scripts/build-kernel.sh ++++ b/scripts/build-kernel.sh +@@ -17,6 +17,12 @@ + TREE="$ROOT/build/linux" + OUT="$ROOT/build/out" + LOGS="$ROOT/logs" ++ ++# The Orchard public producer has no private input mount and embeds no root. ++if [ "${ORCHARD_P105_PUBLIC_KERNEL:-0}" != 1 ] && [ ! -s "$ROOT/build/keep/lib/firmware/mtcal.bin" ]; then ++ echo "owner panel calibration required: supply build/keep/lib/firmware/mtcal.bin before building" >&2 ++ exit 1 ++fi + + export ARCH=arm + export CROSS_COMPILE=arm-linux-gnueabihf- +@@ -86,6 +92,12 @@ + # initrd pointer, and a second irecovery upload would have to land on an address + # iBEC has not already claimed. One blob sidesteps both problems. + INITRAMFS_DIR="${INITRAMFS_DIR:-$ROOT/build/initramfs-root}" ++if [ "${ORCHARD_P105_PUBLIC_KERNEL:-0}" != 1 ]; then ++ [ -s "$INITRAMFS_DIR/lib/firmware/mtcal.bin" ] && cmp -s "$ROOT/build/keep/lib/firmware/mtcal.bin" "$INITRAMFS_DIR/lib/firmware/mtcal.bin" || { ++ echo "initramfs calibration must match the owner's private input" >&2 ++ exit 1 ++ } ++fi + if [ -d "$INITRAMFS_DIR" ]; then + echo "==> Embedding initramfs from $INITRAMFS_DIR" + frag="$(mktemp)" diff --git a/kernel/p105/upstream.lock b/kernel/p105/upstream.lock new file mode 100644 index 00000000..4294733b --- /dev/null +++ b/kernel/p105/upstream.lock @@ -0,0 +1,5 @@ +cascadia_url=https://github.com/rzxvx/Project-Cascadia.git +cascadia_commit=c20b85ebb951c704d7e3a664c378dde8276e3928 +linux_url=https://github.com/torvalds/linux.git +linux_commit=adc218676eef25575469234709c2d87185ca223a +linux_tag=v6.12 diff --git a/pyproject.toml b/pyproject.toml index 791dc011..36fc4c07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,12 @@ package-dir = {orchard_hardware = "."} [tool.setuptools.package-data] orchard_hardware = [ "devices/*/device.toml", "devices/*/firmware.json", "devices/*/public-firmware/*", "schemas/v1/*.json", + "devices/p105/*.md", + "kernel/p105/*.md", "kernel/p105/*.lock", "kernel/p105/*.fragment", "kernel/p105/*.patch", + "kernel/p105/prepare-source", "kernel/p105/build", "kernel/p105/*.sh", "kernel/p105/Containerfile", + "runtime/p105/*.py", "runtime/p105/*.md", "runtime/p105/patches/*.patch", + "runtime/p105/overlay/*", "runtime/p105/overlay/*/*", "runtime/p105/overlay/*/*/*", + "runtime/p105/overlay/*/*/*/*", "runtime/p105/overlay/*/*/*/*/*", "launcher/*.py", "runtime/j71b/*.py", "runtime/j71b/phase2-hardware", "runtime/j71b/j71b-*", "devices/j71b/touch/runtime/touch-activate", "devices/j71b/touch/runtime/*/*.sh", "boot/initramfs/j71b/recovery/*.json", "boot/initramfs/j71b/recovery/Containerfile", diff --git a/runtime/p105/README.md b/runtime/p105/README.md new file mode 100644 index 00000000..f8a9ad03 --- /dev/null +++ b/runtime/p105/README.md @@ -0,0 +1,167 @@ +# P105 installed-system hardware runtime + +This manual adapter targets the original iPad mini Wi-Fi, `iPad2,5` / `p105ap`, +with its A5 ARMv7 processor. It enters an ARMv7 systemd NFS root through a +separately built Cascadia RAM image. Device validation used Arch Linux ARM +`armv7h`. Orchard device RPC and desktop assembly are separate integration work. + +The runtime preserves Cascadia's touchscreen calibration sequence, PMU button +handling and wireless power-cycle timing. Kernel drivers own SGX startup, +Bluetooth patchram, charging and speaker clocks. Public resources here contain +shell scripts, service units, configuration and patches. The installer takes +compiled tools and private firmware from an external verified initramfs. + +## Prepare external inputs + +Use the pinned Cascadia source preparation in [kernel/p105](../../kernel/p105/README.md). +It applies these runtime patches in order against +`c20b85ebb951c704d7e3a664c378dde8276e3928`: + +1. `patches/0001-p105-systemd-handoff.patch` adds the installed-root marker branch. +2. `patches/0002-p105-desktop-home.patch` adds opt-in generic Home forwarding. + +Extract calibration from the target iPad and retain it as a private file. +Supply it to Cascadia as `build/keep/lib/firmware/mtcal.bin` before building. +The selected initramfs must contain that same calibration. The installer +requires a separate, regular `--calibration` file outside the selected +initramfs tree. It rejects a symlink, compares the bytes to the boot tree +before installation and copies the explicit file. Obtain firmware through Cascadia's +extraction flow from the user's own IPSW. + +The source tree contains no Apple firmware, panel calibration, SSH keys or +compiled hardware tools. The exact external payload paths are listed in +`overlay/usr/lib/cascadia/p105-payload.list`. Static ARM tools are `z2-boot`, +`p105-keys` and `busybox.static`. The key tool must include `--desktop-home`. + +## Install into a guest root + +Python 3.9 or later is required. Run this from the hardware source tree or its +installed package directory: + +```sh +python3 runtime/p105/install.py \ + --root /work/p105-root \ + --initramfs /work/cascadia/build/initramfs-root \ + --calibration /work/private/own-panel-mtcal.bin +``` + +The installer requires root to preserve guest ownership. For a staging tree, +use `--allow-user-owned-staging` and apply numeric guest ownership before +exporting it. It rejects the host root, an incompatible root marker, payload +paths escaping the selected tree, non-ARM tools and a calibration mismatch. + +The guest must supply ARM32 systemd, `/usr/bin/sh`, coreutils, grep and udev. +A desktop environment is supplied separately when needed. +Configure its account, SSH authentication, DNS, NFS export and desktop session +in the system assembler. Enable NetworkManager for wireless and keep the +existing USB link configured during the pivot. The runtime's NetworkManager +configuration leaves the pinned USB gadget MAC unmanaged; its link file keeps +`usb0`. Remove inherited network-manager services that compete for that link. + +Installed helper paths remain `/usr/libexec/p105`. The installer enables +`p105-hardware.target` and the physical USB rescue console. It records local +payload hashes in `/usr/lib/cascadia/p105-payload.sha256` and installation +metadata in `p105-runtime.json` beside it. Those generated records stay with +the private guest root. + +## USB and NFS prerequisites + +The tested gadget uses `10.55.0.2/24`; configure its host interface as +`10.55.0.1/24`. Serve the assembled guest root over NFSv3/TCP to that client, +with numeric guest ownership and executable modes preserved. The host must +keep serving that filesystem throughout the Linux session. + +Before building the private Cascadia image, set its root selection file: + +```sh +install -d build/keep/etc +printf '%s\n' '10.55.0.1:/absolute/path/to/exported-root' > build/keep/etc/nfsroot +``` + +The actual export location belongs to the owner's host. Configure the host's +NFS service and USB address before booting. On macOS this needs an interactive +administrator Terminal session and approval for system-configuration access +when requested. NFS and account provisioning belong to the host/image builder. + +## Boot contract + +`/etc/cascadia/systemd-root` contains `p105-systemd-v1`. Cascadia's RAM shell +calls the installed `prepare-root` before moving mounts. It refreshes the +static tools and firmware from the boot image, creates `/run` and `/tmp` as +RAM filesystems and seeds a static rescue shell under `/run/p105-rescue`. +The first stage moves `/dev`, `/proc` and `/sys` with their mounted children, +then enters `/usr/libexec/p105/systemd-init` as PID 1. + +The marker branch skips Alpine stage2 updates. A failed preparation unmounts +its temporary filesystems and returns to Cascadia's RAM stage2. Early USB +reset loops and the timer-sensitive setup remain in the pinned first stage. +Linux-owned internal NAND installation is outside this adapter's contract. + +The ACM serial port exposes an unauthenticated root rescue shell over physical +USB, matching Cascadia's bench recovery behavior. Its shell, applets, working +directory and history live in RAM so the console survives an NFS stall. Keep +that port free of another getty; if using `console=ttyGS0`, mask +`serial-getty@ttyGS0.service` in the guest. + +A desktop session unit should require the hardware target: + +```ini +[Unit] +Requires=p105-hardware.target +After=p105-hardware.target +``` + +The target requires the platform check, persistent `z2-boot` process and key +daemon. Touch calibration continues after the process starts, as in Cascadia's +background launch. Keep `z2-boot` running because it retains the touchscreen +file descriptor after handing reports to the kernel. + +## Hardware behavior + +`p105-wifi-chip.service` waits for a cfg80211 interface. If absent, it waits +15 seconds before cycling the HSIC EHCI host at `36400000.usb`, keeps power +off for one second and retries at most twice. This uses the same bounded +recovery as Cascadia's `wifi chip`. NetworkManager starts afterward and owns +association, addresses and DNS. The USB gadget carrying NFS uses a separate +controller. A radio failure appears in the journal while the desktop can start. + +The target starts native `bluetooth.service`; `hci_bcm` handles the UART, +GPIOs, baud rate and HCD firmware in the kernel. The copied firmware remains +available for controller resets. Cascadia's `asound.conf` provides ALSA's +mixed 48 kHz S16_LE stereo default on card `iPadMini`. The MCA and amplifier +drivers preserve their own power sequence. PipeWire and WirePlumber use their +normal ALSA discovery. Keep SGX autoboot enabled for native DRM/KMS and retain +`apple_pmu_charger.armed=1` in the board's boot arguments. + +`p105-keys.service` opts in with `--desktop-home`. A short screen-on Home release +emits `KEY_HOMEPAGE` down/SYN/up/SYN through the distinct `Cascadia P105 Home` +uinput device. Its udev rule marks it as keyboard input; the compositor binds +the generic key to its action. Exact `gpio-keys` name matching prevents the +daemon from grabbing its virtual device. Cascadia's no-argument RAM launch +retains its existing behavior. The 800 ms hold still controls rotation lock, +with unchanged PMU feedback. Hold, volume and side-switch behavior stay in the +hardware daemon. + +Rebuild the Cascadia bundle whenever its static tools change. Every NFS boot +copies `/sbin/p105-keys` from RAM and refreshes the checksum, so the boot image +and installed unit must support the same option. + +## Verification + +```sh +systemctl --no-pager --failed +systemctl status p105-platform.service p105-touch.service p105-keys.service +journalctl -b -u p105-touch.service -u p105-wifi-chip.service +(cd / && sha256sum -c /usr/lib/cascadia/p105-payload.sha256) +``` + +The touch journal should reach `READY` with a successful ioctl. Check that PID 1 +is systemd, `/` uses the selected NFS export and both `/run` and `/tmp` use tmpfs. +Read `/proc/bus/input/devices` to confirm the physical and virtual keys. Test +Home, screen power, volume and wireless through the physical device, then repeat +boot before making device-support claims. Native package and syntax checks +verify the artifacts; hardware tests establish their behavior. + +Cascadia source and its hardware investigations: +[Project-Cascadia](https://github.com/rzxvx/Project-Cascadia/tree/c20b85ebb951c704d7e3a664c378dde8276e3928). +The runtime scripts and patches use `GPL-2.0-only`, as marked in each source. diff --git a/runtime/p105/install.py b/runtime/p105/install.py new file mode 100755 index 00000000..5f7cd257 --- /dev/null +++ b/runtime/p105/install.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Install the P105 hardware overlay into an assembled ARMv7 systemd root.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import struct +import tempfile + +HERE = Path(__file__).resolve().parent +OVERLAY = HERE / "overlay" +MARKER = "p105-systemd-v1\n" +ENABLE = { + "multi-user.target.wants/p105-hardware.target": "/usr/lib/systemd/system/p105-hardware.target", + "sysinit.target.wants/p105-acm-console.service": "/usr/lib/systemd/system/p105-acm-console.service", +} + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def arm_elf(path, *, static=False): + data = path.read_bytes() + if (len(data) < 52 or data[:7] != b"\x7fELF\x01\x01\x01" + or struct.unpack_from("> 24 != 5 or flags & 0x600 == 0x600: + raise ValueError(f"Expected compatible ARM EABI5 flags: {path}") + if not static and flags & 0x600 != 0x400: + raise ValueError(f"Installed userspace must use the ARM hard-float ABI: {path}") + phoff = struct.unpack_from(" len(data): + raise ValueError(f"Invalid ARM program headers: {path}") + if static and any(struct.unpack_from("/dev/null + sleep 1 +done +echo "P105: ACM respawn limit reached" > /dev/kmsg diff --git a/runtime/p105/overlay/usr/libexec/p105/check-platform b/runtime/p105/overlay/usr/libexec/p105/check-platform new file mode 100755 index 00000000..213249cf --- /dev/null +++ b/runtime/p105/overlay/usr/libexec/p105/check-platform @@ -0,0 +1,17 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +set -eu +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +[ "$(cat /etc/cascadia/systemd-root)" = p105-systemd-v1 ] +tr '\000' '\n' < /proc/device-tree/compatible | grep -qx apple,p105ap +[ "$(uname -m)" = armv7l ] +[ -c /dev/hx-touch ] +[ -c /dev/dri/card0 ] +cd / +sha256sum -c /usr/lib/cascadia/p105-payload.sha256 +# KMS shares memory with fbcon. Keep normal console messages out of graphics. +if [ -w /sys/module/printk/parameters/ignore_loglevel ]; then + printf N > /sys/module/printk/parameters/ignore_loglevel +fi +dmesg -n 1 +cat /proc/sys/kernel/random/boot_id > /run/p105/hardware-ready diff --git a/runtime/p105/overlay/usr/libexec/p105/prepare-root b/runtime/p105/overlay/usr/libexec/p105/prepare-root new file mode 100755 index 00000000..b024d811 --- /dev/null +++ b/runtime/p105/overlay/usr/libexec/p105/prepare-root @@ -0,0 +1,65 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# Run with the initramfs /bin/sh before moving mounts or switching root. +set -eu +export PATH=/sbin:/bin:/usr/sbin:/usr/bin +ROOT=${1:-/newroot} +NFSROOT=${2:-} +[ "$ROOT" = /newroot ] || { echo "p105: expected /newroot" >&2; exit 1; } +[ "$(cat "$ROOT/etc/cascadia/systemd-root" 2>/dev/null)" = p105-systemd-v1 ] +[ -x "$ROOT/usr/lib/systemd/systemd" ] +[ -x "$ROOT/usr/bin/sh" ] +[ -x "$ROOT/usr/libexec/p105/systemd-init" ] +[ -r "$ROOT/usr/lib/cascadia/p105-payload.list" ] + +# The current RAM boot supplies the static tools and private hardware inputs. +# No Alpine executable or policy replaces the installed Arch init system. +while IFS='|' read -r source target mode; do + [ -n "$source" ] || continue + case "$source|$target|$mode" in + 'sbin/z2-boot|usr/libexec/p105/z2-boot|0755'|\ + 'sbin/p105-keys|usr/libexec/p105/p105-keys|0755'|\ + 'bin/busybox.static|usr/libexec/p105/busybox.static|0755'|\ + 'lib/firmware/P105.mtprops|usr/lib/firmware/P105.mtprops|0644'|\ + 'lib/firmware/mtcal.bin|usr/lib/firmware/mtcal.bin|0644'|\ + 'lib/firmware/apple/sgx543.fw|usr/lib/firmware/apple/sgx543.fw|0644'|\ + 'lib/firmware/brcm/brcmfmac4334.bin|usr/lib/firmware/brcm/brcmfmac4334.bin|0644'|\ + 'lib/firmware/brcm/BCM4334B0.hcd|usr/lib/firmware/brcm/BCM4334B0.hcd|0644') ;; + *) echo "p105: unsupported payload entry" >&2; exit 1 ;; + esac + [ -s "/$source" ] + mkdir -p "$ROOT/${target%/*}" + cp "/$source" "$ROOT/$target.tmp.$$" + chmod "$mode" "$ROOT/$target.tmp.$$" + chown 0:0 "$ROOT/$target.tmp.$$" + mv "$ROOT/$target.tmp.$$" "$ROOT/$target" +done < "$ROOT/usr/lib/cascadia/p105-payload.list" +( + cd "$ROOT" + while IFS='|' read -r source target mode; do + [ -z "$source" ] || sha256sum "$target" + done < usr/lib/cascadia/p105-payload.list +) > "$ROOT/usr/lib/cascadia/p105-payload.sha256.tmp.$$" +mv "$ROOT/usr/lib/cascadia/p105-payload.sha256.tmp.$$" "$ROOT/usr/lib/cascadia/p105-payload.sha256" + +# Keep the physical USB rescue console independent of NFS after PID 1 starts. +mkdir -p "$ROOT/run" "$ROOT/tmp" "$ROOT/dev" "$ROOT/proc" "$ROOT/sys" +if ! awk -v d="$ROOT/run" '$2==d && $3=="tmpfs" {f=1} END {exit !f}' /proc/mounts; then + mount -t tmpfs -o mode=755,size=16m,nosuid,nodev run "$ROOT/run" +fi +if ! awk -v d="$ROOT/tmp" '$2==d && $3=="tmpfs" {f=1} END {exit !f}' /proc/mounts; then + mount -t tmpfs -o mode=1777,size=128m,nosuid,nodev tmp "$ROOT/tmp" +fi +mkdir -p "$ROOT/run/p105-rescue/bin" "$ROOT/run/p105" +cp /bin/busybox.static "$ROOT/run/p105-rescue/busybox" +chmod 755 "$ROOT/run/p105-rescue/busybox" +# Symlinks use the post-switch_root path. +for applet in sh getty sleep cat grep awk stat touch rm mkdir mount umount ip dmesg ps; do + ln -sf /run/p105-rescue/busybox "$ROOT/run/p105-rescue/bin/$applet" +done +cp "$ROOT/usr/libexec/p105/acm-rescue" "$ROOT/run/p105-rescue/acm.sh" +chmod 755 "$ROOT/run/p105-rescue/acm.sh" +[ ! -r /etc/build-date ] || cp /etc/build-date "$ROOT/etc/build-date" +cat /proc/sys/kernel/random/boot_id > "$ROOT/run/p105/boot-id" +printf '%s\n' "${NFSROOT:-unknown}" > "$ROOT/run/p105/nfs-root" +echo "P105: Arch systemd root prepared; hardware timing remains in z2-boot" > /dev/kmsg diff --git a/runtime/p105/overlay/usr/libexec/p105/systemd-init b/runtime/p105/overlay/usr/libexec/p105/systemd-init new file mode 100755 index 00000000..eb9d8ecd --- /dev/null +++ b/runtime/p105/overlay/usr/libexec/p105/systemd-init @@ -0,0 +1,17 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# switch_root enters this as PID 1. systemd owns the installed-system lifecycle. +export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin +export HOME=/root USER=root LOGNAME=root TERM=linux +if [ "$$" -ne 1 ]; then + echo "p105: systemd-init must run as PID 1" >&2 + exit 1 +fi +if [ ! -x /usr/lib/systemd/systemd ] || [ ! -x /run/p105-rescue/busybox ]; then + echo "P105: prepared systemd root is incomplete" > /dev/kmsg + while :; do + /usr/libexec/p105/busybox.static sh -i /dev/ttyGS0 2>&1 + /usr/libexec/p105/busybox.static sleep 1 + done +fi +exec /usr/lib/systemd/systemd "$@" diff --git a/runtime/p105/overlay/usr/libexec/p105/wifi-chip b/runtime/p105/overlay/usr/libexec/p105/wifi-chip new file mode 100755 index 00000000..195583e6 --- /dev/null +++ b/runtime/p105/overlay/usr/libexec/p105/wifi-chip @@ -0,0 +1,44 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +# Hardware-only part of Cascadia initramfs/usr/bin/wifi, chip_cycle/need_chip. +# NetworkManager owns association, DHCP and DNS in the installed root. +set -u +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +EHCI=36400000.usb +EHCI_DRV=/sys/bus/platform/drivers/ehci-platform + +chip_present() { + # Accept the interface after a udev rename as well as its kernel wlan0 name. + for net in /sys/class/net/*; do + # brcmfmac's netdev parent is the USB device, whose driver is "usb". + # cfg80211 marks the actual wireless interface with a phy80211 link. + if [ -e "$net/phy80211" ] || [ -d "$net/wireless" ]; then + return 0 + fi + done + return 1 +} + +chip_cycle() { + [ ! -e "$EHCI_DRV/$EHCI" ] || printf '%s\n' "$EHCI" > "$EHCI_DRV/unbind" + sleep 1 + printf '%s\n' "$EHCI" > "$EHCI_DRV/bind" +} + +tries=0 +i=0 +while ! chip_present; do + if [ "$i" -ge 30 ]; then + if [ "$tries" -ge 2 ]; then + echo 'P105: BCM4334 interface absent after two power cycles' >&2 + exit 1 + fi + echo 'P105: BCM4334 absent; cycling its EHCI host' + chip_cycle || exit 1 + tries=$((tries + 1)) + i=0 + fi + sleep 0.5 + i=$((i + 1)) +done +echo 'P105: BCM4334 wireless interface present' diff --git a/runtime/p105/patches/0001-p105-systemd-handoff.patch b/runtime/p105/patches/0001-p105-systemd-handoff.patch new file mode 100644 index 00000000..e99581d6 --- /dev/null +++ b/runtime/p105/patches/0001-p105-systemd-handoff.patch @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: GPL-2.0-only +# Project-Cascadia c20b85ebb951c704d7e3a664c378dde8276e3928 +--- a/initramfs/init ++++ b/initramfs/init +@@ -4,8 +4,8 @@ + # + # Everything here has to happen before a root filesystem can be chosen: the + # early mounts, the clock, the USB gadget, the network. Then it picks a root +-# and execs /sbin/p105-stage2, which starts the services. Two roots are +-# possible and both end in the same stage 2: ++# and enters its init. A marked NFS root supplies an installed systemd init; ++# the existing Cascadia roots keep /sbin/p105-stage2. The available roots are: + # + # NFS -- if /etc/nfsroot exists and the mount succeeds. Survives reboots and + # is not limited to what fits in 512 MB of RAM. +@@ -157,7 +157,36 @@ + done + if [ $nfs_ok = 1 ]; then + msg "P105: NFS root mounted (try $nfs_try)" +- if [ -x /newroot/sbin/p105-stage2 ]; then ++ if [ -e /newroot/etc/cascadia/systemd-root ]; then ++ # The root marker opts in to an installed init. Run the preparation ++ # script with this RAM shell so none of its code depends on the ++ # guest's C library before the switch. Keep this branch ahead of ++ # the legacy stage2 path, which updates an Alpine root in place. ++ if [ "$(cat /newroot/etc/cascadia/systemd-root 2>/dev/null)" = p105-systemd-v1 ] \ ++ && [ -x /newroot/usr/libexec/p105/prepare-root ] \ ++ && [ -x /newroot/usr/libexec/p105/systemd-init ] \ ++ && [ -x /newroot/usr/lib/systemd/systemd ] \ ++ && /bin/sh /newroot/usr/libexec/p105/prepare-root /newroot "$NFSROOT"; then ++ msg "P105: switching root to installed systemd on $NFSROOT" ++ # If a move fails, undo the earlier moves before RAM fallback. ++ if mount --move /dev /newroot/dev 2>/dev/null; then ++ if mount --move /proc /newroot/proc 2>/dev/null; then ++ if mount --move /sys /newroot/sys 2>/dev/null; then ++ exec /bin/busybox.static switch_root /newroot \ ++ /usr/libexec/p105/systemd-init ++ fi ++ mount --move /newroot/proc /proc 2>/dev/null ++ fi ++ mount --move /newroot/dev /dev 2>/dev/null ++ fi ++ msg "P105: installed root mount handoff failed" ++ else ++ msg "P105: marked installed root failed preparation" ++ fi ++ # Preparation may have mounted either RAM filesystem before failing. ++ umount /newroot/tmp 2>/dev/null || true ++ umount /newroot/run 2>/dev/null || true ++ elif [ -x /newroot/sbin/p105-stage2 ]; then + cp /etc/resolv.conf /newroot/etc/resolv.conf 2>/dev/null || true + cp /etc/build-date /newroot/etc/build-date 2>/dev/null || true + # Stage 2 is part of the boot, not of the root filesystem, so the +@@ -230,7 +259,7 @@ + mount --move /sys /newroot/sys 2>/dev/null + exec switch_root /newroot /sbin/p105-stage2 + fi +- msg "P105: $NFSROOT has no /sbin/p105-stage2 -- staying in RAM" ++ msg "P105: $NFSROOT has no usable boot entry -- staying in RAM" + umount /newroot 2>/dev/null + else + msg "P105: NFS mount failed after $nfs_try tries -- staying in RAM" diff --git a/runtime/p105/patches/0002-p105-desktop-home.patch b/runtime/p105/patches/0002-p105-desktop-home.patch new file mode 100644 index 00000000..6300d710 --- /dev/null +++ b/runtime/p105/patches/0002-p105-desktop-home.patch @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: GPL-2.0-only +# Project-Cascadia c20b85ebb951c704d7e3a664c378dde8276e3928 +--- a/tools/p105-keys.c ++++ b/tools/p105-keys.c +@@ -13,6 +13,7 @@ + * hold screen off / on; while it is off the touchscreen is + * grabbed, so a finger on the dark glass types nothing. + * The volume still works with the screen off ++ * home, short press with --desktop-home: a KEY_HOMEPAGE tap through uinput + * home, held 0.8 s the rotation lock on / off (/run/rotation-lock, the + * file tools/desktop/autorotate.sh obeys): the + * backlight dips once for on, twice for off +@@ -24,8 +25,10 @@ + * Built static by ./cascadia build (like z2-boot): one C file, no libraries. + */ + #include ++#include + #include + #include ++#include + #include + #include + #include +@@ -237,13 +240,77 @@ + close(fd); + } + +-int main(void) ++/* A separate device keeps the real buttons grabbed and leaves the desktop's ++ * action to its own key binding. is_buttons() matches only "gpio-keys". */ ++#define DESKTOP_HOME_NAME "Cascadia P105 Home" ++ ++static int desktop_home_open(void) ++{ ++ struct uinput_setup setup = { 0 }; ++ int fd = open("/dev/uinput", O_WRONLY | O_CLOEXEC); ++ ++ if (fd < 0) ++ return -1; ++ setup.id.bustype = BUS_VIRTUAL; ++ setup.id.vendor = 0x0001; ++ setup.id.product = 0x0105; ++ setup.id.version = 1; ++ snprintf(setup.name, sizeof(setup.name), "%s", DESKTOP_HOME_NAME); ++ if (ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0 || ++ ioctl(fd, UI_SET_KEYBIT, KEY_HOMEPAGE) < 0 || ++ ioctl(fd, UI_DEV_SETUP, &setup) < 0 || ++ ioctl(fd, UI_DEV_CREATE) < 0) { ++ int saved = errno; ++ ++ close(fd); ++ errno = saved; ++ return -1; ++ } ++ return fd; ++} ++ ++static int desktop_home_tap(int fd) ++{ ++ const struct input_event events[] = { ++ { .type = EV_KEY, .code = KEY_HOMEPAGE, .value = 1 }, ++ { .type = EV_SYN, .code = SYN_REPORT }, ++ { .type = EV_KEY, .code = KEY_HOMEPAGE, .value = 0 }, ++ { .type = EV_SYN, .code = SYN_REPORT }, ++ }; ++ const char *bytes = (const char *)events; ++ size_t left = sizeof(events); ++ ++ while (left) { ++ ssize_t n = write(fd, bytes, left); ++ ++ if (n < 0 && errno == EINTR) ++ continue; ++ if (n <= 0) { ++ if (!n) ++ errno = EIO; ++ return -1; ++ } ++ bytes += n; ++ left -= n; ++ } ++ return 0; ++} ++ ++int main(int argc, char **argv) + { + char path[300]; + struct input_event ev; + unsigned long sw[BITS(SW_CNT)] = { 0 }; + int keys, touch = -1, max, level, off = 0, backlight_mode; ++ int desktop_home = 0, home_output = -1; + long home = 0; /* when Home went down, while it is held and has not acted */ ++ ++ if (argc == 2 && !strcmp(argv[1], "--desktop-home")) ++ desktop_home = 1; ++ else if (argc != 1) { ++ fprintf(stderr, "usage: %s [--desktop-home]\n", argv[0]); ++ return argc == 2 && !strcmp(argv[1], "--help") ? 0 : 2; ++ } + + for (int tries = 0; (keys = find(is_buttons, path, sizeof(path))) < 0; tries++) { + if (tries == 30) { +@@ -251,6 +318,15 @@ + return 1; + } + sleep(1); ++ } ++ if (desktop_home) { ++ home_output = desktop_home_open(); ++ if (home_output < 0) { ++ perror("p105-keys: desktop Home uinput"); ++ close(keys); ++ return 1; ++ } ++ fprintf(stderr, "desktop Home forwarding on (%s)\n", DESKTOP_HOME_NAME); + } + ioctl(keys, EVIOCGRAB, 1); + ioctl(keys, EVIOCGSW(sizeof(sw)), sw); +@@ -309,6 +385,13 @@ + fprintf(stderr, "side switch %d: the buttons set the %s\n", ev.value, + backlight_mode ? "backlight" : "volume"); + } else if (ev.type == EV_KEY && ev.code == KEY_HOMEPAGE && ev.value != 2) { ++ if (!ev.value && home && home_output >= 0 && !off && ++ now_ms() - home < HOME_HOLD_MS) { ++ if (desktop_home_tap(home_output) < 0) ++ perror("p105-keys: desktop Home tap"); ++ else ++ fprintf(stderr, "desktop Home tap\n"); ++ } + home = ev.value ? now_ms() : 0; + } + } diff --git a/tests/test_p105_components.py b/tests/test_p105_components.py new file mode 100644 index 00000000..1e4bf7c0 --- /dev/null +++ b/tests/test_p105_components.py @@ -0,0 +1,183 @@ +"""Component boundaries using synthetic inputs and disposable files.""" +import argparse +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import struct +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +loader = importlib.machinery.SourceFileLoader('p105_components_test', str(ROOT / 'kernel/p105/build')) +spec = importlib.util.spec_from_loader(loader.name, loader) +producer = importlib.util.module_from_spec(spec) +loader.exec_module(producer) + + +class P105Components(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.base = Path(temporary.name) + + def outputs(self, private=False): + output = self.base / 'components' + output.mkdir() + kernel = bytes(36) + bytes.fromhex('18286f01') + bytes(8) + dtb = bytes.fromhex('d00dfeed') + struct.pack('>I', 40) + bytes(32) + config = '# CONFIG_MTD is not set\nCONFIG_INITRAMFS_SOURCE=""\n' + for name, data in {'zImage': kernel, 'p105ap.dtb': dtb, 'kernel.config': config.encode(), + 'kernel.release': b'6.12.0\n', 'compiler.txt': b'fixture compiler\n', + 'binutils.txt': b'fixture binutils\n'}.items(): + (output / name).write_bytes(data) + if private: + loader_bytes = b'synthetic loader' + (output / 'staging-loader.bin').write_bytes(loader_bytes) + (output / 'staging-bundle.bin').write_bytes(loader_bytes.ljust(32768, b'\0') + kernel + dtb) + (output / 'initramfs.tar').write_bytes(b'synthetic private archive') + return output + + def args(self, **changes): + values = dict(work=self.base / 'work', output=self.base / 'output', + private_initramfs=None, calibration=None, jobs=2, + nfs_root=None, + cascadia_source=None, linux_bundle=None, + toolchain_image='unused', keep_work=False) + values.update(changes) + return argparse.Namespace(**values) + + def test_public_output_refuses_private_payloads_and_unexpected_files(self): + output = self.outputs() + producer.validate_outputs(output, False) + for name in ('initramfs.tar', 'mtcal.bin', 'staging-bundle.bin'): + with self.subTest(name=name): + (output / name).write_bytes(b'synthetic private input') + with self.assertRaisesRegex(ValueError, 'output set'): + producer.validate_outputs(output, False) + (output / name).unlink() + + def test_public_kernel_requires_empty_initramfs_and_disabled_nand(self): + output = self.outputs() + for config in ('CONFIG_INITRAMFS_SOURCE="private-root"\n', + 'CONFIG_INITRAMFS_SOURCE=""\nCONFIG_MTD_APPLE_H2FMI=y\n', + 'CONFIG_INITRAMFS_SOURCE=""\nCONFIG_MTD_APPLE_H2FMI=m\n'): + with self.subTest(config=config): + (output / 'kernel.config').write_text(config) + with self.assertRaises(ValueError): + producer.validate_outputs(output, False) + + def test_private_bundle_must_match_exact_loader_kernel_and_dtb(self): + output = self.outputs(private=True) + producer.validate_outputs(output, True) + bundle = output / 'staging-bundle.bin' + content = bundle.read_bytes() + for data in (content[:-1], content[:32768] + b'changed', b'wrong loader' + content[12:]): + bundle.write_bytes(data) + with self.assertRaisesRegex(ValueError, 'bundle differs'): + producer.validate_outputs(output, True) + + def test_linked_component_is_refused(self): + output = self.outputs() + component = output / 'compiler.txt' + component.unlink() + component.symlink_to('/etc/passwd') + with self.assertRaisesRegex(ValueError, 'output set'): + producer.validate_outputs(output, False) + + def test_private_input_pair_is_required_before_any_external_operation(self): + for args in (self.args(private_initramfs=self.base / 'root'), + self.args(calibration=self.base / 'owner.bin')): + with self.subTest(args=args), patch.object(producer, 'run', side_effect=AssertionError('external operation')): + with self.assertRaisesRegex(ValueError, 'supplied together'): + producer.build(args) + self.assertFalse(args.work.exists()) + + def test_nfs_setting_is_private_and_rejects_multiline_or_relative_inputs(self): + self.assertEqual(producer.nfs_location('10.55.0.1:/export/desktop'), '10.55.0.1:/export/desktop') + for value in ('host:relative', 'host:/root\nother:/root', 'host:/root\x00', '/root', 'host:/'): + with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'nfs-root'): + producer.nfs_location(value) + args = self.args(nfs_root='10.55.0.1:/export/desktop') + with patch.object(producer, 'run', side_effect=AssertionError('external operation')): + with self.assertRaisesRegex(ValueError, 'private boot root'): + producer.build(args) + self.assertFalse(args.work.exists()) + + def test_wrong_calibration_is_refused_before_work_creation(self): + root = self.base / 'root' + (root / 'lib/firmware').mkdir(parents=True) + (root / 'lib/firmware/mtcal.bin').write_bytes(b'panel A synthetic') + calibration = self.base / 'owner.bin' + calibration.write_bytes(b'panel B synthetic') + args = self.args(private_initramfs=root, calibration=calibration) + with patch.object(producer, 'run', side_effect=AssertionError('external operation')): + with self.assertRaisesRegex(ValueError, 'calibration differs'): + producer.build(args) + self.assertFalse(args.work.exists()) + + def test_work_and_output_cannot_alias_the_hardware_package(self): + alias = self.base / 'package' + alias.symlink_to(ROOT, target_is_directory=True) + for destination in (ROOT / 'private-new-build', alias / 'private-new-build'): + with self.subTest(path=destination), self.assertRaisesRegex(ValueError, 'outside'): + producer.fresh(destination) + + def test_overlay_cannot_follow_guest_links_into_host_files(self): + source, guest, outside = (self.base / name for name in ('source', 'guest', 'outside')) + for directory in (source / 'etc', guest, outside): + directory.mkdir(parents=True) + (source / 'etc/test').write_bytes(b'overlay') + (outside / 'test').write_bytes(b'preserve outside') + (guest / 'etc').symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(ValueError, 'Linked overlay destination'): + producer.overlay(source, guest) + self.assertEqual((outside / 'test').read_bytes(), b'preserve outside') + + def test_archive_preserves_links_without_reading_targets_and_maps_ownership(self): + source = self.base / 'source' + source.mkdir() + (source / 'program').write_bytes(b'fixture binary') + (source / 'host-link').symlink_to('/outside/unavailable/private-input') + archive = self.base / 'input.tar' + inventory = producer.archive_tree(source, archive) + self.assertEqual(inventory['host-link'], {'link': '/outside/unavailable/private-input'}) + with tarfile.open(archive) as stream: + for member in stream: + self.assertEqual((member.uid, member.gid), (0, 0)) + self.assertTrue(stream.getmember('host-link').issym()) + + def test_empty_mount_points_survive_private_boot_archiving(self): + root = self.base / 'boot-root' + root.mkdir() + producer.boot_mounts(root) + archive = self.base / 'boot.tar' + inventory = producer.archive_tree(root, archive) + with tarfile.open(archive) as stream: + for name in ('dev', 'proc', 'sys', 'run', 'tmp'): + self.assertTrue(stream.getmember(name).isdir()) + self.assertTrue(inventory[name]['directory']) + self.assertEqual((root / 'tmp').stat().st_mode & 0o7777, 0o1777) + + def test_early_mount_point_cannot_alias_an_outside_directory(self): + root = self.base / 'boot-root' + outside = self.base / 'outside' + root.mkdir(); outside.mkdir() + (root / 'dev').symlink_to(outside, target_is_directory=True) + with self.assertRaisesRegex(ValueError, 'early mount point'): + producer.boot_mounts(root) + + def test_packaged_default_disables_the_reference_flash_controller(self): + preparation = producer.module('p105_component_pins_test', ROOT / 'kernel/p105/prepare-source') + config = preparation.merge_config('CONFIG_MTD=y\nCONFIG_MTD_APPLE_H2FMI=y\n', + (ROOT / 'kernel/p105/config.fragment').read_text()) + self.assertIn('CONFIG_MTD=y', config.splitlines()) + self.assertIn('# CONFIG_MTD_APPLE_H2FMI is not set', config.splitlines()) + self.assertIn('+\t\t\tstatus = "disabled";', (ROOT / 'kernel/p105/nand-disabled.patch').read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_p105_data.py b/tests/test_p105_data.py new file mode 100644 index 00000000..be5963b3 --- /dev/null +++ b/tests/test_p105_data.py @@ -0,0 +1,112 @@ +"""Public P105 identity and recipes contain source metadata, not private payloads.""" +import importlib.util +import json +from pathlib import Path, PurePosixPath +import re +import unittest +from urllib.parse import urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('p105_data_api_test', ROOT / '__init__.py') +hardware = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hardware) + + +class P105Data(unittest.TestCase): + def setUp(self): + self.device = hardware.device('p105') + self.recipe_path = hardware.firmware_path('p105') + self.recipe = json.loads(self.recipe_path.read_text()) + + def test_identity_is_the_wifi_mini_and_has_no_published_launcher_claim(self): + self.assertEqual(self.device['schema'], 1) + self.assertEqual(self.device['kind'], 'hardware.device') + self.assertEqual(self.device['device']['profile'], 'p105') + self.assertEqual(self.device['device']['apple_products'], ['iPad2,5']) + self.assertEqual(self.device['device']['dt_compatible'], ['apple,p105ap']) + self.assertEqual(self.device['match']['dfu'], {'chip': 0x8942, 'board': 0x0a}) + self.assertEqual(self.device['boot']['methods'], ['jailbreak-kdfu']) + self.assertNotIn('launch_manifest', self.device['boot']) + self.assertNotIn('linux', self.device['match']) + self.assertNotIn('distro', self.device) + self.assertEqual(self.recipe['device'], 'iPad2,5') + self.assertEqual(self.recipe['preparation']['mode'], 'manual-source-only') + self.assertIs(self.recipe['preparation']['orchard_adapter'], False) + + def test_public_recipe_and_destinations_are_local_pinned_and_contained(self): + self.assertEqual(self.recipe_path, hardware.device_path('p105').parent / 'firmware.json') + self.assertFalse(self.recipe_path.is_symlink()) + source = urlsplit(self.recipe['ipsw_url']) + self.assertEqual(source.scheme, 'https') + self.assertEqual(source.hostname, 'secure-appldnld.apple.com') + self.assertEqual((self.recipe['version'], self.recipe['build']), ('8.4.1', '12H321')) + self.assertEqual(self.recipe['ipsw_bytes'], 1509764204) + self.assertEqual(self.recipe['ipsw_sha256'], + 'ce6cd59a4a3271eccb93b31c8d40e94ad966112999576663b3ab235196154756') + self.assertRegex(self.recipe['preparation']['source_commit'], r'^[0-9a-f]{40}$') + entries = self.recipe['files'] + self.recipe['generated'] + self.assertEqual(len({entry['destination'] for entry in entries}), len(entries)) + for entry in entries: + destination = PurePosixPath(entry['destination']) + self.assertFalse(destination.is_absolute()) + self.assertNotIn('..', destination.parts) + self.assertEqual(destination.parts[:2], ('lib', 'firmware')) + self.assertRegex(entry['sha256'], r'^[0-9a-f]{64}$') + for pattern in self.recipe['extract_patterns']: + re.compile(pattern) + + def test_own_device_calibration_is_required_and_has_no_public_hash_or_bytes(self): + private = self.recipe['private_inputs'] + self.assertEqual(len(private), 1) + calibration = private[0] + self.assertEqual(calibration['name'], 'mtcal.bin') + self.assertEqual(calibration['destination'], 'lib/firmware/mtcal.bin') + self.assertIs(calibration['required'], True) + self.assertIs(calibration['allow_reference_fallback'], False) + self.assertIs(calibration['publish'], False) + self.assertFalse(set(calibration) & {'sha256', 'sha1', 'md5', 'bytes', 'size', 'data', 'base64'}) + public = self.recipe['files'] + self.recipe['generated'] + self.recipe['extracted_inputs'] + self.assertNotIn('mtcal.bin', {entry['name'] for entry in public}) + self.assertNotIn('lib/firmware/mtcal.bin', {entry.get('destination') for entry in public}) + + def test_public_p105_resources_are_text_without_firmware_keys_or_identity_records(self): + resources = [] + for relative in ('devices/p105', 'runtime/p105', 'kernel/p105'): + directory = ROOT / relative + self.assertTrue(directory.is_dir(), relative) + for path in directory.rglob('*'): + if '__pycache__' in path.parts or path.name == '.DS_Store': + continue + self.assertFalse(path.is_symlink(), str(path)) + if path.is_file(): + resources.append(path) + self.assertIn(ROOT / 'runtime/p105/install.py', resources) + self.assertIn(ROOT / 'kernel/p105/prepare-source', resources) + prohibited_suffixes = {'.bin', '.fw', '.hcd', '.mtprops', '.z2fw', '.ipsw', '.dmg', + '.dfu', '.dtb', '.ko', '.pem', '.key', '.pub', '.pyc'} + private_names = {'mtcal.bin', 'authorized_keys', 'known_hosts', 'identity.json', + 'id_rsa', 'id_ed25519'} + private_fields = {'ecid', 'udid', 'serial_number', 'serialnumber', 'mac_address', 'wifi_mac'} + + def check_fields(value): + if isinstance(value, dict): + self.assertFalse({key.lower() for key in value} & private_fields) + for child in value.values(): + check_fields(child) + elif isinstance(value, list): + for child in value: + check_fields(child) + + for path in resources: + with self.subTest(resource=path.relative_to(ROOT).as_posix()): + self.assertNotIn(path.suffix.lower(), prohibited_suffixes) + self.assertNotIn(path.name.lower(), private_names) + self.assertFalse(path.name.startswith('ssh_host_')) + data = path.read_bytes() + self.assertFalse(data.startswith((b'\x7fELF', b'\x1f\x8b', b'\xfd7zXZ\x00', b'\xcf\xfa\xed\xfe'))) + text = data.decode('utf-8') + self.assertNotRegex(text, r'-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----') + self.assertNotIn('/Users/', text) + if path.suffix == '.json': + check_fields(json.loads(text)) diff --git a/tests/test_p105_prepare.py b/tests/test_p105_prepare.py new file mode 100644 index 00000000..61a7084d --- /dev/null +++ b/tests/test_p105_prepare.py @@ -0,0 +1,234 @@ +"""Offline P105 source preparation contracts using synthetic Git inputs.""" +import argparse +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +LOADER = importlib.machinery.SourceFileLoader( + 'p105_prepare_test', str(ROOT / 'kernel/p105/prepare-source')) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +prepare = importlib.util.module_from_spec(SPEC) +LOADER.exec_module(prepare) + + +class P105ConfigMerge(unittest.TestCase): + def test_fragment_replaces_every_old_definition_and_preserves_other_symbols(self): + base = ('# Original configuration\nCONFIG_KEEP="unchanged"\n' + '# CONFIG_NAMESPACES is not set\nCONFIG_NAMESPACES=n\n' + 'CONFIG_FUSE_FS=n\nCONFIG_PREEMPT=y\n') + fragment = 'CONFIG_NAMESPACES=y\nCONFIG_FUSE_FS=y\n# CONFIG_PREEMPT is not set\n' + result = prepare.merge_config(base, fragment) + self.assertEqual(result.count('CONFIG_NAMESPACES'), 1) + self.assertEqual(result.count('CONFIG_FUSE_FS'), 1) + self.assertEqual(result.count('CONFIG_PREEMPT'), 1) + self.assertIn('CONFIG_KEEP="unchanged"\n', result) + self.assertIn('# Original configuration\n', result) + self.assertIn('CONFIG_NAMESPACES=y\n', result) + self.assertIn('CONFIG_FUSE_FS=y\n', result) + self.assertIn('# CONFIG_PREEMPT is not set\n', result) + + def test_repeated_merge_is_identical_and_accepts_a_changed_fragment(self): + base = '# Upstream\nCONFIG_KEEP=y\n# CONFIG_FUSE_FS is not set\n' + first = prepare.merge_config(base, 'CONFIG_FUSE_FS=y\n') + self.assertEqual(prepare.merge_config(first, 'CONFIG_FUSE_FS=y\n'), first) + replacement = prepare.merge_config(first, '# CONFIG_FUSE_FS is not set\n') + self.assertEqual(replacement.count('CONFIG_FUSE_FS'), 1) + self.assertIn('CONFIG_KEEP=y\n', replacement) + self.assertIn('# CONFIG_FUSE_FS is not set\n', replacement) + self.assertEqual(prepare.merge_config(replacement, '# CONFIG_FUSE_FS is not set\n'), replacement) + + def test_private_initramfs_path_or_an_empty_fragment_is_refused(self): + for fragment in ('CONFIG_INITRAMFS_SOURCE="/private/owner/initramfs"\n', + '# CONFIG_INITRAMFS_SOURCE is not set\n', + '# No selected symbols\n', ''): + with self.subTest(fragment=fragment), self.assertRaises(ValueError): + prepare.merge_config('CONFIG_KEEP=y\n', fragment) + + +@unittest.skipUnless(shutil.which('git') and shutil.which('awk'), 'Git and awk are required') +class P105SourcePreparation(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix='p105-source-test-') + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.repository = self.root / 'upstream' + self.hardware = self.root / 'hardware' + self.here = self.hardware / 'kernel/p105' + self.repository.mkdir() + self.here.mkdir(parents=True) + # Keep fixture commits independent of the operator's hooks and signing. + environment = patch.dict(os.environ, { + 'GIT_CONFIG_GLOBAL': os.devnull, 'GIT_CONFIG_NOSYSTEM': '1', + 'GIT_AUTHOR_NAME': 'P105 Test Fixture', + 'GIT_AUTHOR_EMAIL': 'p105-fixture@example.invalid', + 'GIT_COMMITTER_NAME': 'P105 Test Fixture', + 'GIT_COMMITTER_EMAIL': 'p105-fixture@example.invalid', + }) + environment.start() + self.addCleanup(environment.stop) + self.reference = b'SYNTHETIC-REFERENCE-CALIBRATION\x00\x01\x02' + files = { + '.gitignore': b'build/\n', + 'README.txt': b'Synthetic public Cascadia source\n', + 'config/p105ap.config': (b'CONFIG_KEEP=y\n# CONFIG_NAMESPACES is not set\n' + b'CONFIG_NAMESPACES=n\nCONFIG_FUSE_FS=n\n'), + 'scripts/check-config-fragment.awk': ( + b'/^(# )?CONFIG_/ { line=$0; sub(/^# /,"",line); ' + b'split(line,p,/=| /); if (seen[p[1]]++) exit 1 }\n'), + prepare.EXCLUDED[0]: self.reference, + } + targets = ('handoff', 'home', 'calibration-gate', 'native-container', 'nand-disabled') + for name in targets: + files['fixture/' + name + '.txt'] = ('before-' + name + '\n').encode() + for name, data in files.items(): + path = self.repository / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + self.git('init', '-q') + self.git('add', '.') + self.git('-c', 'commit.gpgsign=false', 'commit', '-qm', 'Synthetic source fixture') + self.commit = self.git('rev-parse', 'HEAD').strip() + self.lock = { + 'cascadia_url': 'https://github.com/rzxvx/Project-Cascadia.git', + 'cascadia_commit': self.commit, + 'linux_url': 'https://github.com/torvalds/linux.git', + 'linux_commit': '1' * 40, 'linux_tag': 'v6.12', + } + patch_paths = [self.hardware / name for name in prepare.RUNTIME_PATCHES] + patch_paths += [self.here / 'private-calibration.patch', self.here / 'native-container.patch', + self.here / 'nand-disabled.patch'] + for path, name in zip(patch_paths, targets): + path.parent.mkdir(parents=True, exist_ok=True) + relative = 'fixture/' + name + '.txt' + path.write_text('diff --git a/{0} b/{0}\n--- a/{0}\n+++ b/{0}\n' + '@@ -1 +1 @@\n-before-{1}\n+after-{1}\n'.format(relative, name)) + (self.here / 'config.fragment').write_text('CONFIG_NAMESPACES=y\nCONFIG_FUSE_FS=y\n') + + def git(self, *arguments): + return subprocess.check_output(['git', '-C', str(self.repository), *arguments], + text=True, stderr=subprocess.PIPE) + + @contextlib.contextmanager + def resources(self): + with patch.multiple(prepare, HERE=self.here, HARDWARE=self.hardware), \ + patch.object(prepare, 'pins', return_value=dict(self.lock)): + yield + + def run_prepare(self, output, source=None): + args = argparse.Namespace(source=self.repository if source is None else source, output=output) + with self.resources(), contextlib.redirect_stdout(io.StringIO()): + prepare.prepare(args) + + def assert_refused_before_export(self, output, message, source=None): + with patch.object(prepare, 'export_source') as export: + with self.assertRaisesRegex(ValueError, message): + self.run_prepare(output, source) + export.assert_not_called() + + def test_clean_source_exports_only_public_tracked_files_and_applies_resources(self): + # These bytes belong to the synthetic fixture. No real calibration is read. + (self.repository / prepare.EXCLUDED[0]).write_bytes(b'SYNTHETIC-LOCAL-REFERENCE-EDIT') + private = self.repository / 'build/keep/lib/firmware/mtcal.bin' + private.parent.mkdir(parents=True) + private.write_bytes(b'SYNTHETIC-OWNER-INPUT') + output = self.root / 'private-output' + archived = {} + original_export = prepare.export_source + + def inspect_archive(repository, commit, paths, destination, archive_path): + original_export(repository, commit, paths, destination, archive_path) + archived['bytes'] = archive_path.read_bytes() + with tarfile.open(archive_path, 'r:') as archive: + archived['names'] = archive.getnames() + + with patch.object(prepare, 'export_source', side_effect=inspect_archive): + self.run_prepare(output) + self.assertEqual((output / 'README.txt').read_text(), 'Synthetic public Cascadia source\n') + for name in ('handoff', 'home', 'calibration-gate', 'native-container', 'nand-disabled'): + self.assertEqual((output / 'fixture' / (name + '.txt')).read_text(), 'after-' + name + '\n') + self.assertFalse((output / '.git').exists()) + self.assertFalse((output / 'build').exists()) + self.assertFalse((output / prepare.EXCLUDED[0]).exists()) + self.assertNotIn(prepare.EXCLUDED[0], archived['names']) + for value in (self.reference, b'SYNTHETIC-LOCAL-REFERENCE-EDIT', b'SYNTHETIC-OWNER-INPUT'): + self.assertNotIn(value, archived['bytes']) + config = (output / 'config/p105ap.config').read_text() + self.assertIn('CONFIG_KEEP=y\n', config) + self.assertEqual(config.count('CONFIG_NAMESPACES'), 1) + self.assertIn('CONFIG_NAMESPACES=y\n', config) + self.assertIn('CONFIG_FUSE_FS=y\n', config) + receipt = json.loads((output / 'orchard-p105-source.json').read_text()) + self.assertEqual(receipt['upstream']['cascadia_commit'], self.commit) + self.assertEqual(receipt['excluded_tracked_paths'], list(prepare.EXCLUDED)) + self.assertIs(receipt['private_build_outputs'], True) + self.assertEqual(len(receipt['patches']), 5) + + def test_wrong_commit_is_refused_before_export_or_output_creation(self): + self.lock['cascadia_commit'] = '0' * 40 + output = self.root / 'new-parent/output' + self.assert_refused_before_export(output, 'revision differs') + self.assertFalse(output.parent.exists()) + + def test_dirty_public_tracked_source_is_refused_before_output_creation(self): + (self.repository / 'README.txt').write_text('Uncommitted public source edit\n') + output = self.root / 'new-parent/output' + self.assert_refused_before_export(output, 'modified tracked source') + self.assertFalse(output.parent.exists()) + + def test_untracked_nonignored_source_is_refused_before_output_creation(self): + (self.repository / 'unexpected-private-input').write_bytes(b'SYNTHETIC-UNTRACKED') + output = self.root / 'new-parent/output' + self.assert_refused_before_export(output, 'untracked non-ignored') + self.assertFalse(output.parent.exists()) + + def test_existing_outputs_are_preserved_including_a_dangling_symlink(self): + directory = self.root / 'existing-directory' + directory.mkdir() + sentinel = directory / 'keep.txt' + sentinel.write_bytes(b'preserve directory') + file_path = self.root / 'existing-file' + file_path.write_bytes(b'preserve file') + link = self.root / 'dangling-output' + link.symlink_to('absent-target') + for output in (directory, file_path, link): + with self.subTest(output=output.name): + self.assert_refused_before_export(output, 'fresh output') + self.assertEqual(sentinel.read_bytes(), b'preserve directory') + self.assertEqual(file_path.read_bytes(), b'preserve file') + self.assertTrue(link.is_symlink()) + self.assertEqual(os.readlink(link), 'absent-target') + + def test_output_inside_hardware_or_input_checkout_is_refused(self): + for output, message in ((self.hardware / 'private-build', 'outside the hardware'), + (self.repository / 'private-build', 'outside the input')): + with self.subTest(output=output.parent.name): + self.assert_refused_before_export(output, message) + self.assertFalse(output.exists()) + + def test_output_alias_into_hardware_checkout_is_refused(self): + alias = self.root / 'hardware-alias' + alias.symlink_to(self.hardware, target_is_directory=True) + output = alias / 'private-build' + self.assert_refused_before_export(output, 'outside the hardware') + self.assertFalse((self.hardware / 'private-build').exists()) + + def test_source_must_be_a_checkout_root(self): + output = self.root / 'new-parent/output' + self.assert_refused_before_export(output, 'checkout root', self.repository / 'fixture') + self.assertFalse(output.parent.exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_p105_runtime.py b/tests/test_p105_runtime.py new file mode 100644 index 00000000..4a811af9 --- /dev/null +++ b/tests/test_p105_runtime.py @@ -0,0 +1,225 @@ +"""P105 installation guards using synthetic payloads and temporary guest roots.""" +import contextlib +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import struct +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('p105_installer_test', ROOT / 'runtime/p105/install.py') +installer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(installer) +CALIBRATION = b'synthetic own-device calibration; never an Apple reference blob\n' + + +def arm_elf(*, dynamic=False, home=True): + ident = b'\x7fELF\x01\x01\x01' + bytes(9) + header = struct.pack('<16sHHIIIIIHHHHHH', ident, 2, 40, 1, 0, 52, 0, + 0x05000400, 52, 32, 1, 40, 0, 0) + program = struct.pack(' Date: Tue, 27 Oct 2026 18:14:34 -0600 Subject: [PATCH 2/3] Add P105 RAM recovery protocol and NFS handoff --- MANIFEST.in | 1 + devices/p105/README.md | 27 ++- devices/p105/device.toml | 2 +- docs/p105-orchard-validation.md | 77 ++++++++ docs/p105-validation.md | 12 +- kernel/p105/README.md | 26 ++- kernel/p105/build | 41 ++++- kernel/p105/build-in-container.sh | 6 +- kernel/p105/recovery.fragment | 7 + pyproject.toml | 3 +- runtime/p105/README.md | 16 +- runtime/p105/recovery/device.py | 284 ++++++++++++++++++++++++++++++ runtime/p105/recovery/init | 71 ++++++++ tests/test_p105_components.py | 25 +++ tests/test_p105_recovery.py | 77 ++++++++ 15 files changed, 651 insertions(+), 24 deletions(-) create mode 100644 docs/p105-orchard-validation.md create mode 100644 kernel/p105/recovery.fragment create mode 100644 runtime/p105/recovery/device.py create mode 100644 runtime/p105/recovery/init create mode 100644 tests/test_p105_recovery.py diff --git a/MANIFEST.in b/MANIFEST.in index 0647061b..1042abfc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -14,5 +14,6 @@ recursive-include devices/p105 *.md *.toml *.json recursive-include kernel/p105 *.md *.lock *.fragment *.patch *.sh Containerfile prepare-source build recursive-include runtime/p105 *.py *.md *.patch recursive-include runtime/p105/overlay * +recursive-include runtime/p105/recovery *.py init prune runtime/p105/build prune kernel/p105/.work diff --git a/devices/p105/README.md b/devices/p105/README.md index c1109542..6a2740b4 100644 --- a/devices/p105/README.md +++ b/devices/p105/README.md @@ -2,8 +2,10 @@ Experimental hardware prerequisites and component builds for the original iPad mini Wi-Fi. The public resources describe the board and its firmware -inputs; the local boot path uses Cascadia and Legacy iOS Kit. An Orchard CLI -adapter and published P105 launch manifest remain pending. +inputs; the boot components use Cascadia and vetted Legacy iOS Kit tools. +The companion Orchard CLI contribution supplies a native P105 adapter for +private tethered recovery and USB/NFS handoff. Public launch assets await +release integration. | Identity | Value | | --- | --- | @@ -15,10 +17,11 @@ adapter and published P105 launch manifest remain pending. | Physical RAM | 512 MiB | | Panel | 768 x 1024 | -The Linux CDC gadget product string is generic. The descriptor records DFU -identity and device-tree compatibility and leaves Linux product matching -unset. The Wi-Fi, USB serial and USB network capability flags describe -observed hardware behavior. Provisioning still needs runtime confirmation. +The native recovery gadget uses `Orchard-P105`, with a boot-bound USB serial +number. macOS reports its product as `Orchard_P105`. The device descriptor +retains the generic CDC gadget metadata used by the earlier manual boot; +native discovery validates the recovery identity through the CLI adapter. +The capability flags describe observed hardware behavior. ## Manual boot prerequisites @@ -65,8 +68,9 @@ describes the host setup and jailbreak-assisted USB boot sequence. firmware.json retains the shared device, IPSW, extracted-input and destination fields used by the existing J71b recipe. Its preparation mode is explicitly manual and source-only. The encrypted root filesystem needs Cascadia's pinned -extractor, and generated entries name Cascadia source steps. An Orchard -firmware-preparation adapter has yet to implement those steps. +extractor, and generated entries name Cascadia source steps. The CLI verifies +the owner's prepared firmware against this recipe. Extraction remains an +explicit owner preparation step. P105.mtprops is generic Apple touchscreen firmware extracted from the IPSW. The separate mtcal.bin comes from the owner's panel calibration in syscfg @@ -100,6 +104,10 @@ experience with the earlier desktop. This remains an open issue. See the [component validation record](../../docs/p105-validation.md) for the tested scope and the package checks. +The companion CLI subsequently passed repeated native recovery boots and NFS +handoff, preserving its pinned diagnostics and the RAM rescue console. See +[the Orchard validation record](../../docs/p105-orchard-validation.md). + ## Earlier physical qualification The results below describe the original manual build and its desktop root. @@ -127,4 +135,5 @@ still need qualification. Linux reboot currently powers the device off; starting Linux again requires the host boot flow. Tethered boot and the external root remain part of this qualified setup. P105 registration and manual component preparation cover this contribution; release integration -and the Orchard CLI adapter remain separate work. +remains part of later release work. The companion CLI adapter handles the +private tethered recovery and NFS workflow. diff --git a/devices/p105/device.toml b/devices/p105/device.toml index 08274f6c..feedb983 100644 --- a/devices/p105/device.toml +++ b/devices/p105/device.toml @@ -14,7 +14,7 @@ board = 0x0a [boot] methods = ["jailbreak-kdfu"] -# The Orchard CLI adapter and published launch manifest remain pending. +# The CLI consumes an owner-private boot manifest. Public launch assets await release integration. [firmware] recipe = "firmware.json" diff --git a/docs/p105-orchard-validation.md b/docs/p105-orchard-validation.md new file mode 100644 index 00000000..fa3783a0 --- /dev/null +++ b/docs/p105-orchard-validation.md @@ -0,0 +1,77 @@ +# P105 Orchard recovery validation + +On 27 October 2026, Orchard CLI 0.1.11 booted the original iPad mini Wi-Fi +through its qualified iOS 9.3.5 jailbreak into RAM recovery. The authenticated +NFS handoff reached the preserved Arch Linux ARM / Omarchy A desktop. A second +cold boot qualified the final console cleanup in the recovery init. + +The local contributions pair hardware 0.1.11 with CLI 0.1.11 and the ARMv7 +recovery composer in Minimal Linux. Each repository has a separate branch. +The desktop filesystem still comes from the preserved development root; its +migration into the current Omarchy A builder belongs to phase 4. + +## Physical checks + +| Check | Result | +| --- | --- | +| Boot transport | UDID-bound iOS SSH, verified kloader and iBEC, then one bundle upload and one loader upload | +| Cable wait continuation | The repeat test resumed its saved iBEC wait with the same target and verified assets | +| Recovery identity | Provisioned owner ECID and live kernel boot UUID checked through USB, serial bootstrap and pinned SSH | +| NFS handoff | One authenticated request; the same device, boot UUID and SSH host key remained valid after the pivot | +| Installed system | NFS root ready, graphical target active, Labwc and desktop shell running | +| Service health after touch recovery | Zero failed system units; platform, touch and Home services active | +| Hardware payload | All eight installed checksums passed | +| Rescue console | Exactly one reader of `ttyGS0`, rooted in the installed system; physical serial commands ran the rescue shell from RAM | +| Flash fence | Controller disabled and MTD inventory empty | +| Owner interaction | Desktop visible; physical Home opened the app grid on the first native boot | + +The repeat boot's touchscreen daemon exhausted its calibration attempts and +exited. A single service restart restored it in the same Linux session. The +driver reported missing attention signals during that failed startup. This +intermittent calibration failure remains open alongside the slow touch and +repeated taps observed in the earlier desktop. The owner accepted slow touch +for the tethered milestone. Logs retain the failed startup and recovery. + +The final repeat used installed wheels while Python audit hooks denied reads +from all five Orchard checkouts and the original source workspace. The host's +NFS service continued serving the existing desktop dataset. These hooks +exercise package independence within the Python processes; the kernel and +NFS service have their own access to the exported root. + +## Host and package checks + +The CLI suite passes 161 tests on Python 3.9.6 and 3.14.6. Its adapter type +checks pass for both profiles. Hardware passes 107 tests on both versions; +Minimal Linux passes 47 tests. Fixtures cover ambiguous USB discovery and +unrelated Apple devices, alongside identity checks during re-enumeration. + +The hardware wheel and source archive carry all 41 P105 resources with bytes +matching the checkout. CLI archives include every P105 module. Package audits +check for owner calibration, private access material and generated boot files. +The Minimal Linux base uses signed Alpine ARMv7 packages. Public userspace +checks ran under QEMU's Cortex-A9 model before physical qualification. + +The recovery kernel retains P105's namespace, FUSE and zram settings. Its USB +fragment selects configfs ACM/ECM and disables `CONFIG_USB_CDC_COMPOSITE`. +Source tests cover the managed configuration block and refuse output paths +inside the owner's input tree. + +macOS supplied `Orchard_P105` as the product name and masked the link MAC in +`ifconfig`. The adapter accepts the measured product spelling and obtains the +network interface and MAC from children of the selected USB device. Existing +host addresses remain owned by their original setup. + +## Remaining work + +The host supplies boot components and serves the root throughout the session. +The owner manages the existing NFS export. Orchard records any address it adds +to the selected USB link and provides a cleanup command after shutdown. + +Internal installation requires the later A5 storage work. Bluetooth pairing, +audio playback and accelerated graphics still need physical tests. Public +release assembly follows the migrated ARMv7 desktop builder. Private firmware, +calibration and patched Apple boot components stay on the owner's host. + +Private receipts and device logs are retained under the owner's local P105 +qualification directory. The earlier manual component test remains recorded +in [p105-validation.md](p105-validation.md). diff --git a/docs/p105-validation.md b/docs/p105-validation.md index ba313223..0eb6b34c 100644 --- a/docs/p105-validation.md +++ b/docs/p105-validation.md @@ -1,12 +1,15 @@ # P105 component validation +This record covers phase 2. The later native CLI boot and recovery checks are +recorded in [p105-orchard-validation.md](p105-orchard-validation.md). + The P105 contribution builds ARMv7 kernel components from pinned Cascadia and Linux source. An explicit private initramfs selects a boot bundle containing the owner's firmware. The public producer embeds an empty initramfs. ## Host checks -The complete hardware suite passes 98 tests on Python 3.9.6 and Python 3.14.6. +The phase 2 hardware suite passed 98 tests on Python 3.9.6 and Python 3.14.6. These include source export, matching owner calibration and ARM ABI checks. The component tests reject a public output containing a private payload and verify the exact layout of a private bundle. @@ -18,7 +21,7 @@ coverage for empty mount points and linked destinations. The optional NFS location is accepted only for private roots. Tests reject relative locations and embedded line breaks before any build work starts. -The wheel and source archive contain all 38 P105 resources with bytes matching +The phase 2 wheel and source archive contained all 38 P105 resources with bytes matching the checkout. A fresh environment installs the wheel without a sibling source dependency. The installed runtime stages its payload into a disposable ARMv7 guest root using real ARM executables and the new build's static tools. @@ -65,7 +68,8 @@ hashes and the device checks on the owner's host. The earlier Wi-Fi, application and display measurements in the device profile remain attributed to the original manual build. This test qualifies the new -hardware components with the preserved desktop root. The Orchard CLI adapter -and a desktop built entirely from the new checkouts await later phases. +hardware components with the preserved desktop root. The companion CLI phase +has since passed the native qualification linked above. A desktop built +entirely from the new checkouts awaits its builder phase. Bluetooth pairing, audio playback and accelerated graphics need device tests. The current boot still needs the host transport and its NFS export. diff --git a/kernel/p105/README.md b/kernel/p105/README.md index 47b63469..24856b6c 100644 --- a/kernel/p105/README.md +++ b/kernel/p105/README.md @@ -5,7 +5,8 @@ the original iPad mini Wi-Fi (`iPad2,5` / A1432 / `p105ap`). The installed-syste handoff and Home-button patches live in [runtime/p105](../../runtime/p105/). P105 uses Cascadia's `zImage` and staging loader. `kernel/build p105` produces public kernel components or an explicitly private boot bundle. The Orchard -CLI adapter and published launch manifest remain separate work. +CLI adapter consumes an owner-private boot manifest. Public launch assets +remain part of release integration. ## Source pins and scope @@ -100,6 +101,23 @@ before updating the device qualification claim. ## Private inputs and outputs +An explicit owner root containing `etc/orchard/p105.json` selects Orchard RAM +recovery. Minimal Linux's P105 composer produces that root from signed ARMv7 +Alpine packages and the owner's hardware inputs. The config records its P105 +ECID and release. The component receipt adds `mode: orchard-recovery` and +`device_ecid`; keep this receipt with the private bundle. + +This mode combines the ordinary kernel fragment with `recovery.fragment`, +preserving the NAND fence and the installed system's prerequisites. It disables +`CONFIG_USB_CDC_COMPOSITE` and uses configfs for the ECM/ACM gadget. Userspace +publishes the selected ECID and live kernel boot ID before SSH bootstrap. +The initramfs waits for an authenticated NFS request through `orchard-device`. + +The required USB symbol check in the pinned kernel script selects +`CONFIG_USB_LIBCOMPOSITE` for this mode. The component wrapper then verifies +configfs is enabled and the original composite gadget is disabled. The +ordinary private desktop recipe keeps its existing boot path. + Cascadia's pinned source tracks a reference panel calibration at `initramfs/lib/firmware/mtcal.bin`. Preparation excludes that pathname before exporting file data. The reference is never read, hashed or copied by this @@ -111,8 +129,10 @@ Capture the calibration from the owner's iPad, or supply a previous capture from that same panel. A filename and matching bytes establish consistency; the operator remains responsible for choosing the owner's capture. -The final kernel embeds its initramfs. That includes Apple firmware, panel -calibration and SSH access material, including the Linux SSH host key. Keep +The final kernel embeds its initramfs, including Apple firmware and panel +calibration. An ordinary owner root can also contain SSH access material. +The Orchard recovery composer clears inherited SSH keys and generates its +diagnostic host key in RAM after serial bootstrap. Keep the prepared/build tree, container volumes, kernel, initramfs and staging bundles outside this hardware checkout. Treat those build outputs as private. They belong outside public releases, CI artifacts and source packages. diff --git a/kernel/p105/build b/kernel/p105/build index b528abe9..614d3c9c 100755 --- a/kernel/p105/build +++ b/kernel/p105/build @@ -142,6 +142,20 @@ def nfs_location(value): return value +def recovery_config(prepared): + preparation = module('p105_recovery_config', HERE / 'prepare-source') + config = prepared / 'config/p105ap.config' + # merge_config replaces its managed block, so pass the complete policy. + fragment = (HERE / 'config.fragment').read_text() + '\n' + (HERE / 'recovery.fragment').read_text() + config.write_text(preparation.merge_config(config.read_text(), fragment)) + script = prepared / 'scripts/build-kernel.sh' + text = script.read_text() + expected = 'CONFIG_USB_CDC_COMPOSITE CONFIG_UNIX98_PTYS' + if text.count(expected) != 1: + raise ValueError('Pinned kernel USB check differs from the recovery recipe') + script.write_text(text.replace(expected, 'CONFIG_USB_LIBCOMPOSITE CONFIG_UNIX98_PTYS')) + + def stage_private(source, calibration, prepared, work, nfs_root=None): check_private(source, calibration) stage = work / 'private-root' @@ -149,6 +163,18 @@ def stage_private(source, calibration, prepared, work, nfs_root=None): boot_mounts(stage) overlay(prepared / 'rootfs/alpine', stage) overlay(prepared / 'initramfs', stage) + recovery = local_target(stage, 'etc/orchard/p105.json') + if recovery.exists(): + record = json.loads(recovery.read_text()) + if record.get('profile') != 'p105' or type(record.get('ecid')) is not int or not 0 < record['ecid'] < 2**64: + raise ValueError('Invalid private P105 recovery identity') + if nfs_root is not None: + raise ValueError('Orchard RAM recovery accepts NFS through its authenticated handoff') + shutil.copyfile(HARDWARE / 'runtime/p105/recovery/init', stage / 'init') + (stage / 'init').chmod(0o755) + local_target(stage, 'etc/profile').write_text("export PATH=/usr/sbin:/usr/bin:/sbin:/bin HOME=/root PS1='/ # '\n") + local_target(stage, 'etc/nfsroot').unlink(missing_ok=True) + recovery_config(prepared) for path in stage.rglob('*'): if path.name in ('nandctl', 'iosnand', 'ios') and (path.is_file() or path.is_symlink()): path.unlink() @@ -222,6 +248,8 @@ def validate_outputs(output, private): def build(args): + producer_digest = sha(HERE / 'build') + recipe_digest = sha(HERE / 'build-in-container.sh') work, output = fresh(args.work), fresh(args.output) if work in output.parents or output in work.parents or work == output: raise ValueError('Work and output directories must be separate') @@ -234,6 +262,9 @@ def build(args): if nfs_root is not None and not private: raise ValueError('--nfs-root requires an explicitly private boot root') if private: + source_root = args.private_initramfs.resolve() + if work.is_relative_to(source_root) or output.is_relative_to(source_root): + raise ValueError('Keep work and output outside the private input root') check_private(args.private_initramfs, args.calibration) # Validate private inputs before network, output creation or VM operations. os.umask(0o077) @@ -244,13 +275,13 @@ def build(args): prepared = work / 'cascadia' prepare.prepare(argparse.Namespace(source=args.cascadia_source, output=prepared)) lock = prepare.pins() - source = archive_tree(prepared, inputs / 'source.tar') linux_archive(work, lock, args.linux_bundle) private_receipt = None if private: stage = stage_private(args.private_initramfs, args.calibration, prepared, work, nfs_root) private_receipt = archive_tree(stage, inputs / 'initramfs.tar') shutil.copyfile(args.calibration, inputs / 'calibration') + source = archive_tree(prepared, inputs / 'source.tar') shutil.copyfile(HERE / 'build-in-container.sh', inputs / 'build-in-container.sh') inspect = subprocess.run(['podman', 'image', 'inspect', args.toolchain_image, '--format', '{{.Id}}'], capture_output=True, text=True) @@ -285,12 +316,16 @@ def build(args): 'architecture': 'armv7h', 'private': private, 'publishable': not private, 'flash_controller': 'disabled', 'upstream': lock, 'prepared_source': source, 'toolchain_image': image, - 'recipe_sha256': sha(HERE / 'build-in-container.sh'), - 'producer_sha256': sha(HERE / 'build'), + 'recipe_sha256': recipe_digest, + 'producer_sha256': producer_digest, 'components': inventory(temporary), 'qualification': 'build-only; physical-device validation required'} if private: receipt['private_inputs'] = private_receipt + identity = work / 'private-root/etc/orchard/p105.json' + if identity.exists(): + receipt['device_ecid'] = json.loads(identity.read_text())['ecid'] + receipt['mode'] = 'orchard-recovery' (temporary / 'components.json').write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') if output.exists() or output.is_symlink(): raise ValueError('Output appeared during the build; refusing to replace it') diff --git a/kernel/p105/build-in-container.sh b/kernel/p105/build-in-container.sh index 64583e74..682c64b0 100755 --- a/kernel/p105/build-in-container.sh +++ b/kernel/p105/build-in-container.sh @@ -38,13 +38,17 @@ if grep -Eq '^CONFIG_MTD_APPLE_H2FMI=[ym]$' "$config"; then exit 1 fi grep -qx 'CONFIG_EXTRA_FIRMWARE=""' "$config" +if [[ "$mode" == private && -f build/initramfs-root/etc/orchard/p105.json ]]; then + grep -qx 'CONFIG_USB_CONFIGFS=y' "$config" + grep -qx '# CONFIG_USB_CDC_COMPOSITE is not set' "$config" +fi dtc -I dts -O dtb dts/p105ap.dts -o output/p105ap.dtb 2>logs/dtc.log test "$(fdtget output/p105ap.dtb /soc@30000000/flash-controller@1200000 status)" = disabled if [[ "$mode" == public ]]; then grep -qx 'CONFIG_INITRAMFS_SOURCE=""' "$config" else cmp build/keep/lib/firmware/mtcal.bin build/initramfs-root/lib/firmware/mtcal.bin - for marker in 'P105: stage1 start' p105-systemd-v1 ttyGS0 10.55.0.2; do + for marker in ttyGS0 10.55.0.2; do LC_ALL=C grep -aFq "$marker" build/linux/usr/initramfs_data.cpio done python3 scripts/cpio-owners.py build/linux/usr/initramfs_data.cpio > output/cpio-owners.txt diff --git a/kernel/p105/recovery.fragment b/kernel/p105/recovery.fragment new file mode 100644 index 00000000..ff886148 --- /dev/null +++ b/kernel/p105/recovery.fragment @@ -0,0 +1,7 @@ +# A serial number containing the live boot ID requires a userspace USB gadget. +# CONFIG_USB_CDC_COMPOSITE is not set +CONFIG_CONFIGFS_FS=y +CONFIG_USB_LIBCOMPOSITE=y +CONFIG_USB_CONFIGFS=y +CONFIG_USB_CONFIGFS_ACM=y +CONFIG_USB_CONFIGFS_ECM=y diff --git a/pyproject.toml b/pyproject.toml index 36fc4c07..437fddea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "orchard-hardware" -version = "0.1.10" +version = "0.1.11" description = "Hardware support, public recipes, runtime and image construction" readme = "README.md" license = "GPL-2.0-only AND GPL-2.0-or-later AND MIT AND ISC" @@ -29,6 +29,7 @@ orchard_hardware = [ "kernel/p105/*.md", "kernel/p105/*.lock", "kernel/p105/*.fragment", "kernel/p105/*.patch", "kernel/p105/prepare-source", "kernel/p105/build", "kernel/p105/*.sh", "kernel/p105/Containerfile", "runtime/p105/*.py", "runtime/p105/*.md", "runtime/p105/patches/*.patch", + "runtime/p105/recovery/*.py", "runtime/p105/recovery/init", "runtime/p105/overlay/*", "runtime/p105/overlay/*/*", "runtime/p105/overlay/*/*/*", "runtime/p105/overlay/*/*/*/*", "runtime/p105/overlay/*/*/*/*/*", "launcher/*.py", "runtime/j71b/*.py", "runtime/j71b/phase2-hardware", "runtime/j71b/j71b-*", diff --git a/runtime/p105/README.md b/runtime/p105/README.md index f8a9ad03..b9106054 100644 --- a/runtime/p105/README.md +++ b/runtime/p105/README.md @@ -1,9 +1,11 @@ # P105 installed-system hardware runtime -This manual adapter targets the original iPad mini Wi-Fi, `iPad2,5` / `p105ap`, +This runtime targets the original iPad mini Wi-Fi, `iPad2,5` / `p105ap`, with its A5 ARMv7 processor. It enters an ARMv7 systemd NFS root through a separately built Cascadia RAM image. Device validation used Arch Linux ARM -`armv7h`. Orchard device RPC and desktop assembly are separate integration work. +`armv7h`. The [recovery sources](recovery/) add a limited Orchard device RPC +for status, diagnostics, serial SSH bootstrap and an authenticated NFS handoff. +Desktop assembly belongs to the image builder. The runtime preserves Cascadia's touchscreen calibration sequence, PMU button handling and wireless power-cycle timing. Kernel drivers own SGX startup, @@ -66,6 +68,11 @@ the private guest root. ## USB and NFS prerequisites +Native Orchard recovery accepts its NFS location through the authenticated +device protocol. Its producer replaces the manual init and removes any +inherited `etc/nfsroot`. Follow the companion CLI's `docs/p105.md` for that +flow. The root selection below applies to the manual Cascadia boot. + The tested gadget uses `10.55.0.2/24`; configure its host interface as `10.55.0.1/24`. Serve the assembled guest root over NFSv3/TCP to that client, with numeric guest ownership and executable modes preserved. The host must @@ -85,6 +92,11 @@ when requested. NFS and account provisioning belong to the host/image builder. ## Boot contract +The Orchard RAM init waits for one identity-bound handoff request. It prepares +the installed root, moves the early mounts and retires its first serial shell +before entering systemd. Diagnostics keep their pinned SSH key through a RAM +listener on port 2222. The installed serial rescue service then owns `ttyGS0`. + `/etc/cascadia/systemd-root` contains `p105-systemd-v1`. Cascadia's RAM shell calls the installed `prepare-root` before moving mounts. It refreshes the static tools and firmware from the boot image, creates `/run` and `/tmp` as diff --git a/runtime/p105/recovery/device.py b/runtime/p105/recovery/device.py new file mode 100644 index 00000000..b8e71ba5 --- /dev/null +++ b/runtime/p105/recovery/device.py @@ -0,0 +1,284 @@ +#!/usr/bin/python3 +"""P105 RAM recovery RPC. Internal storage operations are absent.""" +import base64 +import fcntl +import json +import os +from pathlib import Path +import re +import shutil +import signal +import stat +import subprocess +import sys +import time +import uuid + +RUN = Path('/run/orchard') +TARGET = Path('/newroot') +CONFIG = Path('/etc/orchard/p105.json') +BOOT_ID = Path('/proc/sys/kernel/random/boot_id') +COMPATIBLE = Path('/sys/firmware/devicetree/base/compatible') +FLASH = Path('/sys/firmware/devicetree/base/soc@30000000/flash-controller@1200000/status') +MTD = Path('/sys/class/mtd') + + +def command(*args, timeout=15, **kwargs): + return subprocess.run(list(args), check=True, capture_output=True, timeout=timeout, **kwargs).stdout + + +def atomic(path, value): + temporary = path.with_suffix('.pending') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + + +def config(): + path = RUN/'config.json' + return json.loads((path if path.exists() else CONFIG).read_text()) + + +def fence(): + if b'apple,p105ap' not in COMPATIBLE.read_bytes().split(b'\0'): + raise ValueError('Expected P105 device tree') + if FLASH.read_bytes().rstrip(b'\0\n') != b'disabled' or list(MTD.glob('mtd[0-9]*')): + raise ValueError('P105 flash controller fence failed') + + +def identity(): + fence() + settings = config() + ecid = settings['ecid'] + if settings.get('profile') != 'p105' or type(ecid) is not int or not 0 < ecid < 2**64: + raise ValueError('Invalid P105 owner identity') + return {'profile': 'p105', 'ecid': ecid, 'boot_id': str(uuid.UUID(BOOT_ID.read_text().strip()))} + + +def initialize(): + if os.geteuid() != 0 or RUN.is_symlink(): + raise ValueError('Recovery identity needs protected RAM state') + RUN.mkdir(parents=True, exist_ok=True, mode=0o700) + RUN.chmod(0o700) + atomic(RUN/'config.json', config()) + value = identity() + atomic(RUN/'identity.json', value) + print('ECID:%016x BOOT:%s CPID:8942 BDID:a' % (value['ecid'], value['boot_id'])) + + +def root_type(): + rows = [line.split()[2] for line in Path('/proc/mounts').read_text().splitlines() + if len(line.split()) >= 3 and line.split()[1] == '/'] + return rows[-1] if rows else 'unknown' + + +def bound(request): + live = identity() + if type(request.get('ecid')) is not int or any(request.get(k) != v for k, v in live.items()): + raise ValueError('Request differs from this device and boot') + return live + + +def status(_): + transitioning = False + try: + live = identity() + root = root_type() + except FileNotFoundError as error: + # Mount moves can briefly hide proc/sys from an already-running RPC. + # A queued, identity-bound handoff permits a pending reply during that + # window. Readiness still requires the live flash fence after pivot. + if error.filename not in (str(COMPATIBLE), str(FLASH), str(BOOT_ID), '/proc/mounts'): + raise + queued = json.loads((RUN/'handoff.json').read_text()) + live = json.loads((RUN/'identity.json').read_text()) + if (live.get('ecid') != config()['ecid'] or live.get('profile') != 'p105' or + any(queued.get(key) != value for key, value in live.items())): + raise ValueError('Transition identity differs from its queued handoff') + transitioning, root = True, 'handoff' + failure = RUN/'failure.json' + ready = False + if root in ('nfs', 'nfs4'): + result = subprocess.run(['systemctl', 'is-active', 'p105-hardware.target', 'default.target'], + capture_output=True, timeout=5) + ready = result.returncode == 0 + return {**live, 'release': config()['release'], 'mode': 'handoff' if transitioning else ('nfs' if root.startswith('nfs') else 'recovery'), + 'root_filesystem': root, 'ready': ready, 'flash_controller': 'disabled', + 'failure': json.loads(failure.read_text()) if failure.exists() else None, + 'operations': ['status', 'diagnostics'] + ([] if transitioning or root.startswith('nfs') else ['handoff']), + 'capabilities': ['status', 'diagnostics'] + ([] if transitioning or root.startswith('nfs') else ['handoff-nfs'])} + + +def bootstrap(request): + live = bound(request) + if os.environ.get('SSH_CONNECTION') or root_type().startswith('nfs'): + raise ValueError('Provision SSH from the physically attached RAM serial console') + public = request.get('public_key', '') + if not re.fullmatch(r'ssh-ed25519 [A-Za-z0-9+/=]{60,128}', public): + raise ValueError('Expected one Ed25519 public key') + session = Path('/run/recovery') + session.mkdir(mode=0o700, exist_ok=True) + marker = session/'bootstrap.started' + fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + os.close(fd) + folder = Path('/root/.ssh') + folder.mkdir(mode=0o700, exist_ok=True) + authorized = session/'authorized_keys' + authorized.write_text(public+'\n') + authorized.chmod(0o600) + target = folder/'authorized_keys' + if target.is_symlink(): + raise ValueError('Linked authorization file refused') + target.touch(mode=0o600, exist_ok=True) + command('mount', '--bind', str(authorized), str(target)) + key = session/'hostkey' + command('/usr/bin/dropbearkey', '-t', 'ed25519', '-f', str(key)) + output = command('/usr/bin/dropbearkey', '-y', '-f', str(key)).decode() + matches = re.findall(r'^ssh-ed25519 [A-Za-z0-9+/=]+', output, re.M) + if len(matches) != 1: + raise ValueError('Expected one recovery host key') + with (session/'ssh.log').open('ab') as log: + subprocess.Popen(['/usr/sbin/dropbear', '-F', '-E', '-s', '-g', '-j', '-k', + '-P', str(session/'dropbear.pid'), '-r', str(key), '-p', '10.55.0.2:2222'], + stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True) + deadline = time.monotonic()+8 + while not (session/'dropbear.pid').exists(): + if time.monotonic() >= deadline: + raise RuntimeError('RAM SSH listener did not start; inspect /run/recovery/ssh.log') + time.sleep(0.05) + return {**live, 'host': '10.55.0.2', 'port': 2222, 'host_key': matches[0]} + + +def nfs_location(value): + if not isinstance(value, str) or not re.fullmatch(r'10\.55\.0\.1:/[A-Za-z0-9._/-]+', value) or '/..' in value or value.endswith('/'): + raise ValueError('Expected the host USB NFS export') + return value + + +def handoff(request): + live = bound(request) + if root_type().startswith('nfs'): + raise ValueError('This boot has already handed off') + source = nfs_location(request.get('nfs_root')) + operation = str(uuid.UUID(request['operation_id'])) + record = {**live, 'operation_id': operation, 'nfs_root': source, 'requested_at': time.monotonic()} + path = RUN/'handoff.json' + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(record, stream) + stream.flush() + os.fsync(stream.fileno()) + return {**live, 'operation_id': operation, 'status': 'accepted', 'nfs_root': source} + + +def contained(root, relative): + path = root/relative + if not path.resolve().is_relative_to(root.resolve()): + raise ValueError('NFS runtime destination escapes its root') + return path + + +def copy(source, target, mode=0o755): + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + raise ValueError('Linked runtime destination refused') + shutil.copyfile(source, target) + target.chmod(mode) + + +def prepare_pivot(): + request = json.loads((RUN/'handoff.json').read_text()) + bound(request) + source = nfs_location(request['nfs_root']) + if time.monotonic()-request['requested_at'] < 2: + time.sleep(2) + TARGET.mkdir(exist_ok=True) + command('mount', '-t', 'nfs', '-o', 'nolock,vers=3,proto=tcp,rsize=8192,wsize=8192,retry=0,timeo=50,retrans=1', + source, str(TARGET), timeout=30) + try: + if contained(TARGET, 'etc/cascadia/systemd-root').read_text().strip() != 'p105-systemd-v1': + raise ValueError('NFS root needs the qualified P105 systemd runtime') + header = contained(TARGET, 'usr/lib/systemd/systemd').read_bytes()[:52] + if len(header) != 52 or header[:7] != b'\x7fELF\x01\x01\x01' or header[18:20] != b'\x28\x00': + raise ValueError('NFS systemd must be an ARM32 executable') + python = contained(TARGET, 'usr/bin/python3') + if not python.is_file(): + raise ValueError('NFS diagnostics require the root Python runtime') + command('/bin/sh', str(contained(TARGET, 'usr/libexec/p105/prepare-root')), str(TARGET), source, timeout=30) + run = contained(TARGET, 'run') + shutil.copytree(RUN, run/'orchard', dirs_exist_ok=True) + shutil.copytree('/run/recovery', run/'recovery', dirs_exist_ok=True) + diagnostics = run/'p105-orchard' + diagnostics.mkdir(mode=0o700) + # Carry the Alpine SSH executable and its small musl dependency closure + # in RAM. The installed userspace keeps its own C library and SSH policy. + shutil.copytree('/usr/libexec/orchard-p105/ssh', diagnostics, dirs_exist_ok=True) + copy(Path(__file__), contained(TARGET, 'usr/libexec/orchard-p105/device.py')) + wrapper = contained(TARGET, 'usr/bin/orchard-device') + wrapper.write_text('#!/bin/sh\nexec /usr/bin/python3 /usr/libexec/orchard-p105/device.py "$@"\n') + wrapper.chmod(0o755) + authorized = contained(TARGET, 'root/.ssh/authorized_keys') + authorized.parent.mkdir(mode=0o700, exist_ok=True) + if authorized.is_symlink(): + raise ValueError('Linked installed authorization file refused') + authorized.touch(mode=0o600, exist_ok=True) + command('mount', '--bind', str(run/'recovery/authorized_keys'), str(authorized)) + units = run/'systemd/system' + units.mkdir(parents=True, exist_ok=True) + (units/'orchard-p105-ssh.service').write_text('[Unit]\nDescription=Orchard P105 boot diagnostics\n' + 'After=local-fs.target\n[Service]\nType=simple\n' + 'ExecStart=/run/p105-orchard/lib/ld-musl-armhf.so.1 --library-path /run/p105-orchard/lib ' + '/run/p105-orchard/dropbear -F -E -s -g -j -k -P /run/recovery/dropbear.pid ' + '-r /run/recovery/hostkey -p 10.55.0.2:2222\n' + '[Install]\nWantedBy=multi-user.target\n') + wants = units/'multi-user.target.wants' + wants.mkdir(exist_ok=True) + (wants/'orchard-p105-ssh.service').symlink_to('/run/systemd/system/orchard-p105-ssh.service') + except BaseException: + for suffix in ('root/.ssh/authorized_keys', 'tmp', 'run', ''): + subprocess.run(['umount', str(TARGET/suffix)], capture_output=True, timeout=5) + raise + + +def diagnostics(_): + result = status({}) + result['kernel'] = os.uname().release + result['mtd_entries'] = [p.name for p in MTD.glob('mtd[0-9]*')] + result['handoff'] = json.loads((RUN/'handoff.json').read_text()) if (RUN/'handoff.json').exists() else None + result['log'] = command('dmesg').decode(errors='replace')[-8192:] + return result + + +def main(): + operation = sys.argv[1] if len(sys.argv) > 1 else 'status' + if operation == 'identity': + initialize() + return + if operation == 'pivot': + try: + prepare_pivot() + except BaseException as error: + atomic(RUN/'failure.json', {'operation': 'handoff', 'error': str(error)}) + raise + return + try: + request = json.loads(base64.urlsafe_b64decode(sys.argv[2])) if len(sys.argv) > 2 else {'protocol': 1} + if request.get('protocol') != 1 or operation not in ('status', 'diagnostics', 'bootstrap', 'handoff'): + raise ValueError('Operation unavailable in P105 recovery') + lock = os.open(RUN/'rpc.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = {'status': status, 'diagnostics': diagnostics, 'bootstrap': bootstrap, 'handoff': handoff}[operation](request) + finally: + os.close(lock) + print(json.dumps({'protocol': 1, 'event': 'result', 'operation': operation, 'result': result}), flush=True) + except (ValueError, OSError, KeyError, subprocess.SubprocessError) as error: + print(json.dumps({'protocol': 1, 'event': 'error', 'operation': operation, 'message': str(error)}), flush=True) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/runtime/p105/recovery/init b/runtime/p105/recovery/init new file mode 100644 index 00000000..5fe164f5 --- /dev/null +++ b/runtime/p105/recovery/init @@ -0,0 +1,71 @@ +#!/bin/sh +# P105 RAM PID 1. Wait for an authenticated request before mounting an NFS root. +export PATH=/usr/sbin:/usr/bin:/sbin:/bin HOME=/root TERM=linux PS1='/ # ' +BB=/bin/busybox.static +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devtmpfs devtmpfs /dev +mkdir -p /dev/pts /run /tmp +mount -t devpts devpts /dev/pts +mount -t tmpfs -o mode=755,size=32m,nosuid,nodev run /run +mount -t tmpfs -o mode=1777,size=64m,nosuid,nodev tmp /tmp +[ ! -f /etc/build-date ] || date -u -s "$(cat /etc/build-date)" +mkdir -p /run/orchard +chmod 700 /run/orchard +if serial=$(/usr/bin/orchard-device identity); then + mount -t configfs configfs /sys/kernel/config + G=/sys/kernel/config/usb_gadget/orchard-p105 + mkdir -p "$G" + echo 0x05ac > "$G/idVendor" + echo 0x4142 > "$G/idProduct" + echo 0x0200 > "$G/bcdUSB" + mkdir -p "$G/strings/0x409" + printf '%s' "$serial" > "$G/strings/0x409/serialnumber" + echo Orchard > "$G/strings/0x409/manufacturer" + echo Orchard-P105 > "$G/strings/0x409/product" + mkdir -p "$G/configs/c.1/strings/0x409" "$G/functions/acm.usb0" "$G/functions/ecm.usb0" + echo 'Orchard P105 recovery' > "$G/configs/c.1/strings/0x409/configuration" + echo 250 > "$G/configs/c.1/MaxPower" + echo 02:10:5a:05:00:01 > "$G/functions/ecm.usb0/host_addr" + echo 02:10:5a:05:00:02 > "$G/functions/ecm.usb0/dev_addr" + ln -s "$G/functions/acm.usb0" "$G/configs/c.1/" + ln -s "$G/functions/ecm.usb0" "$G/configs/c.1/" + for udc in /sys/class/udc/*; do + [ -e "$udc" ] && basename "$udc" > "$G/UDC" + done + ip link set lo up + ip link set usb0 up + ip addr add 10.55.0.2/24 dev usb0 +fi +echo 'Orchard P105 RAM recovery; waiting for host SSH bootstrap' > /dev/kmsg +( + while :; do + "$BB" getty -i -n -l /bin/sh 115200 ttyGS0 & + getty=$! + echo "$getty" > /run/orchard/getty.pid + wait "$getty" + "$BB" sleep 1 + done +) & +console=$! +while :; do + if [ -f /run/orchard/handoff.json ] && [ ! -f /run/orchard/handoff.attempted ]; then + touch /run/orchard/handoff.attempted + if /usr/bin/orchard-device pivot > /run/orchard/pivot.log 2>&1; then + if mount --move /dev /newroot/dev; then + if mount --move /proc /newroot/proc; then + if mount --move /sys /newroot/sys; then + kill "$console" 2>/dev/null + [ ! -f /run/orchard/getty.pid ] || kill -KILL "$(cat /run/orchard/getty.pid)" 2>/dev/null + [ ! -f /run/recovery/dropbear.pid ] || kill "$(cat /run/recovery/dropbear.pid)" 2>/dev/null + exec "$BB" switch_root /newroot /usr/libexec/p105/systemd-init + fi + mount --move /newroot/proc /proc + fi + mount --move /newroot/dev /dev + fi + echo '{"operation":"handoff","error":"mount handoff failed"}' > /run/orchard/failure.json + fi + fi + "$BB" sleep 1 +done diff --git a/tests/test_p105_components.py b/tests/test_p105_components.py index 1e4bf7c0..8a66d9b3 100644 --- a/tests/test_p105_components.py +++ b/tests/test_p105_components.py @@ -119,6 +119,16 @@ def test_wrong_calibration_is_refused_before_work_creation(self): producer.build(args) self.assertFalse(args.work.exists()) + def test_private_input_cannot_contain_build_work_or_output(self): + source = self.base / 'owner-root' + source.mkdir() + for changes in ({'work': source / 'work'}, {'output': source / 'components'}): + args = self.args(private_initramfs=source, calibration=self.base/'calibration', **changes) + with patch.object(producer, 'run', side_effect=AssertionError('external operation')): + with self.assertRaisesRegex(ValueError, 'outside the private input'): + producer.build(args) + self.assertFalse(args.work.exists()) + def test_work_and_output_cannot_alias_the_hardware_package(self): alias = self.base / 'package' alias.symlink_to(ROOT, target_is_directory=True) @@ -178,6 +188,21 @@ def test_packaged_default_disables_the_reference_flash_controller(self): self.assertIn('# CONFIG_MTD_APPLE_H2FMI is not set', config.splitlines()) self.assertIn('+\t\t\tstatus = "disabled";', (ROOT / 'kernel/p105/nand-disabled.patch').read_text()) + def test_recovery_keeps_the_flash_fence_and_systemd_prerequisites(self): + prepared = self.base / 'prepared' + (prepared / 'config').mkdir(parents=True) + (prepared / 'scripts').mkdir() + config = prepared / 'config/p105ap.config' + config.write_text('CONFIG_MTD_APPLE_H2FMI=y\nCONFIG_USB_CDC_COMPOSITE=y\n') + (prepared / 'scripts/build-kernel.sh').write_text('CONFIG_USB_CDC_COMPOSITE CONFIG_UNIX98_PTYS\n') + producer.recovery_config(prepared) + lines = config.read_text().splitlines() + for line in ('CONFIG_NAMESPACES=y', 'CONFIG_USER_NS=y', 'CONFIG_MEMCG=y', + 'CONFIG_ZRAM_BACKEND_LZ4=y', 'CONFIG_FUSE_FS=y', 'CONFIG_USB_CONFIGFS=y', + '# CONFIG_MTD_APPLE_H2FMI is not set', '# CONFIG_USB_CDC_COMPOSITE is not set'): + self.assertIn(line, lines) + self.assertNotIn('CONFIG_MTD_APPLE_H2FMI=y', lines) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_p105_recovery.py b/tests/test_p105_recovery.py new file mode 100644 index 00000000..09d5ea6e --- /dev/null +++ b/tests/test_p105_recovery.py @@ -0,0 +1,77 @@ +"""P105 recovery identity, flash fence and one-shot NFS request validation.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +SPEC=importlib.util.spec_from_file_location('p105_device_test',Path(__file__).resolve().parents[1]/'runtime/p105/recovery/device.py') +device=importlib.util.module_from_spec(SPEC);SPEC.loader.exec_module(device) +BOOT='11111111-1111-4111-8111-111111111111' + + +class Recovery(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + root=Path(self.temp.name) + self.run=root/'run';self.run.mkdir() + self.mtd=root/'mtd';self.mtd.mkdir() + self.compatible=root/'compatible';self.compatible.write_bytes(b'apple,p105ap\0') + self.flash=root/'flash';self.flash.write_bytes(b'disabled\0') + self.boot=root/'boot';self.boot.write_text(BOOT) + self.config=root/'config';self.config.write_text(json.dumps({'profile':'p105','ecid':1,'release':'test'})) + for name,value in {'RUN':self.run,'MTD':self.mtd,'COMPATIBLE':self.compatible,'FLASH':self.flash, + 'BOOT_ID':self.boot,'CONFIG':self.config}.items(): + p=patch.object(device,name,value);p.start();self.addCleanup(p.stop) + + def request(self,**values): + return {'profile':'p105','ecid':1,'boot_id':BOOT,'operation_id':BOOT, + 'nfs_root':'10.55.0.1:/root',**values} + + def test_identity_uses_the_live_kernel_boot_id(self): + self.assertEqual(device.identity(),{'profile':'p105','ecid':1,'boot_id':BOOT}) + new='22222222-2222-4222-8222-222222222222';self.boot.write_text(new) + self.assertEqual(device.identity()['boot_id'],new) + + def test_enabled_flash_or_any_mtd_device_blocks_operations(self): + self.flash.write_bytes(b'okay\0') + with self.assertRaisesRegex(ValueError,'fence'):device.identity() + self.flash.write_bytes(b'disabled\0');(self.mtd/'mtd0').mkdir() + with self.assertRaisesRegex(ValueError,'fence'):device.identity() + + def test_wrong_board_blocks_identity(self): + self.compatible.write_bytes(b'apple,j71b\0') + with self.assertRaisesRegex(ValueError,'P105'):device.identity() + + def test_handoff_rejects_wrong_device_and_boot_before_writing(self): + for change in ({'ecid':2},{'boot_id':'22222222-2222-4222-8222-222222222222'},{'ecid':True}): + with self.subTest(change=change),self.assertRaises(ValueError):device.handoff(self.request(**change)) + self.assertFalse((self.run/'handoff.json').exists()) + + def test_handoff_is_bound_and_accepted_once(self): + with patch.object(device,'root_type',return_value='rootfs'): + result=device.handoff(self.request()) + self.assertEqual(result['status'],'accepted') + self.assertEqual(result['ecid'],1) + with self.assertRaises(FileExistsError):device.handoff(self.request()) + + def test_nfs_status_exposes_no_storage_operations(self): + with patch.object(device,'root_type',return_value='rootfs'): + result=device.status({}) + self.assertEqual(result['operations'],['status','diagnostics','handoff']) + self.assertEqual(result['capabilities'],['status','diagnostics','handoff-nfs']) + self.assertFalse(result['ready']) + + def test_mount_transition_stays_pending_until_live_fence_returns(self): + live=device.identity() + (self.run/'identity.json').write_text(json.dumps(live)) + (self.run/'handoff.json').write_text(json.dumps({**live,'nfs_root':'10.55.0.1:/root'})) + with patch.object(device,'identity',side_effect=FileNotFoundError(2,'moving',str(self.compatible))): + result=device.status({}) + self.assertEqual(result['mode'],'handoff') + self.assertFalse(result['ready']) + self.assertNotIn('handoff-nfs',result['capabilities']) + + +if __name__ == '__main__':unittest.main() From 407c9101c0066ecdfa1a1ad2750ef91947450141 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:29:36 -0600 Subject: [PATCH 3/3] Preserve upstream hardware README text with P105 additions --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0972ffc1..a00d112c 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ Hardware support and image-building resources for Linux on Apple devices. This repository owns device descriptions, kernel patches/configuration, firmware recipes, recovery tooling and the device runtime. It does not depend on a desktop distribution or the host CLI. -## Hardware profiles +## Supported hardware -| Device | Profile | Boot status | -| --- | --- | --- | -| iPad 6 Wi-Fi (`iPad7,5`) | J71b | Experimental tethered Orchard launcher; software rendering | -| iPad mini 1 Wi-Fi (`iPad2,5`, P105AP) | P105 | Experimental manual bring-up through an existing jailbreak, with a host-served NFS root | +Currently: iPad 6 Wi-Fi (`iPad7,5`, J71b), experimental. Boot is tethered and GPU acceleration is not available. -[P105 prerequisites and measured results](devices/p105/README.md) describe the tested A5/ARMv7 path. Its packaged runtime and pinned Cascadia preparation recipe support manual image assembly. Orchard CLI adapter integration and published launcher assets are separate work. +The iPad mini 1 Wi-Fi (`iPad2,5`, P105AP) has an experimental P105 profile for +jailbreak-assisted tethered boot and a host-served NFS root. See the +[P105 prerequisites and measured results](devices/p105/README.md) for its +A5/ARMv7 component recipe and companion Orchard CLI support. Public launcher +assets await release integration. ## Use