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..1042abfc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,3 +9,11 @@ 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 * +recursive-include runtime/p105/recovery *.py init +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..a00d112c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Hardware support and image-building resources for Linux on Apple devices. This r Currently: iPad 6 Wi-Fi (`iPad7,5`, J71b), experimental. Boot is tethered and GPU acceleration is not available. +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 For installing and starting a published system, use the [Orchard CLI](https://github.com/OrchardLinux/cli). Its release requirements select compatible CLI and hardware wheels automatically. diff --git a/devices/p105/README.md b/devices/p105/README.md new file mode 100644 index 00000000..6a2740b4 --- /dev/null +++ b/devices/p105/README.md @@ -0,0 +1,139 @@ +# 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 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 | +| --- | --- | +| 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 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 + +- 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. 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 +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. + +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. + +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 +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 new file mode 100644 index 00000000..feedb983 --- /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 CLI consumes an owner-private boot manifest. Public launch assets await release integration. + +[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-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 new file mode 100644 index 00000000..0eb6b34c --- /dev/null +++ b/docs/p105-validation.md @@ -0,0 +1,75 @@ +# 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 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. + +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 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. + +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 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/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..24856b6c --- /dev/null +++ b/kernel/p105/README.md @@ -0,0 +1,302 @@ +# 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 consumes an owner-private boot manifest. Public launch assets +remain part of release integration. + +## 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 + +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 +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, 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. + +## 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..614d3c9c --- /dev/null +++ b/kernel/p105/build @@ -0,0 +1,363 @@ +#!/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 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' + shutil.copytree(source, stage, symlinks=True) + 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() + 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): + 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') + 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: + 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) + 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() + 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) + 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': 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') + 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..682c64b0 --- /dev/null +++ b/kernel/p105/build-in-container.sh @@ -0,0 +1,71 @@ +#!/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" +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 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/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/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..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" @@ -25,6 +25,13 @@ 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/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-*", "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..b9106054 --- /dev/null +++ b/runtime/p105/README.md @@ -0,0 +1,179 @@ +# P105 installed-system hardware runtime + +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`. 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, +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 + +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 +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 + +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 +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/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 new file mode 100644 index 00000000..8a66d9b3 --- /dev/null +++ b/tests/test_p105_components.py @@ -0,0 +1,208 @@ +"""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_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) + 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()) + + 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_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_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() 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('