diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57527376..73186d98 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -168,7 +168,7 @@ jobs: - name: Install the combined toolchain once if: inputs.reuse_run_id == '' run: | - bash ci/install-toolchains recovery m1n1 + bash ci/install-toolchains recovery recovery-arm m1n1 echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Build static recovery tools if: inputs.reuse_run_id == '' @@ -176,6 +176,16 @@ jobs: OMA_RECOVERY_TOOLS_WORK="$PWD/out/recovery-tools" bash boot/initramfs/recovery/build-tools mkdir -p out/components/boot cp out/recovery-tools/bin/* out/components/boot/ + - name: Build the ARMv7 tools and the boards' Cascadia loaders + if: inputs.reuse_run_id == '' + run: | + OMA_RECOVERY_TOOLS_WORK="$PWD/out/recovery-tools-arm" bash boot/initramfs/recovery/build-tools --arch arm + mkdir -p out/components/boot-arm + cp out/recovery-tools-arm/bin/* out/components/boot-arm/ + for profile in devices/*/cascadia.toml; do + board=$(basename "$(dirname "$profile")") + python3 boot/cascadia/build --profile "$profile" --output "$PWD/out/components/cascadia/$board" + done - name: Build quiet loader if: inputs.reuse_run_id == '' run: | @@ -353,13 +363,20 @@ jobs: # distribution builds its release from, so it stops copying any of them. python -m orchard.image.platforms_index out/publication --kernels out/platforms \ --tools out/components/boot --commit "$GITHUB_SHA" + # The same for 32-bit roots, next to it: platforms-armv7.toml. + loaders=() + for loader in out/components/cascadia/*/loader.bin; do + loaders+=(--loader "$(basename "$(dirname "$loader")")=$loader") + done + python -m orchard.image.platforms_index out/publication --architecture arm --kernels out/platforms \ + --tools out/components/boot-arm --commit "$GITHUB_SHA" "${loaders[@]}" # tool-components.tar.gz stays: a distribution build takes its tools from it. rm out/publication/kernel-components-*.tar.gz out/publication/quiet-pongo.tar.gz # Source keys of each platform's kernel, m1n1 and the tools: a distribution build # with the same state uses these instead of building them (orchard.published). python -m orchard.published keys --output out/publication/component-keys.json apple-16k apple-4k apple-armv7 git archive --format=tar.gz -o out/publication/orchard-source.tar.gz HEAD - (cd out/publication && sha256sum ./*.tar.gz ./*.bin ./*.pyz ./component-keys.json ./platforms.toml > SHA256SUMS) + (cd out/publication && sha256sum ./*.tar.gz ./*.bin ./*.pyz ./component-keys.json ./platforms.toml ./platforms-armv7.toml > SHA256SUMS) - name: Attest public build if: toJSON(github.event.repository.private) == 'false' id: attest diff --git a/boot/cascadia/NOTICE b/boot/cascadia/NOTICE new file mode 100644 index 00000000..2912ab3f --- /dev/null +++ b/boot/cascadia/NOTICE @@ -0,0 +1,16 @@ +The ARMv7 cache-maintenance and Linux handoff sequence is derived from the +Project Cascadia A5 staging loader: + + https://github.com/rzxvx/Project-Cascadia + reference revision: 091d441f19e101699f7e1835a80ccb48d6e54eec + license: GPL-2.0 + +The local P105 work fixed the executable entry by discarding ARM unwind +metadata and asserting the upload address in the linker script. + +This producer keeps those entry safeguards. Hardware addresses come from +the device's profile. Orchard's configuration record replaces the loader's +compiled-in DTB address and Cascadia's overlay header; the initramfs is +copied before the MMU is turned off and the DTB's initrd placeholders are set +in place, as Cascadia's linux_boot.c and assemble.py do. Not yet booted on +hardware in this form. diff --git a/boot/cascadia/README.md b/boot/cascadia/README.md new file mode 100644 index 00000000..bf659508 --- /dev/null +++ b/boot/cascadia/README.md @@ -0,0 +1,48 @@ +# ARM32 iBEC staging payload (Cascadia loader) + +What starts Linux on Apple's 32-bit boards in place of m1n1: a patched iBEC +loads one file at the board's `load_address` and jumps to it. The file is +this loader, the board's zImage with its DTB appended, and an initramfs. + +`build --profile devices/BOARD/cascadia.toml --output DIR` builds the loader +with Orchard's kernel toolchain through `buildenv` (orchard does this in +`orchard build`, see `build.prepare_payload_loader`). The output directory +contains `loader.bin`, its ELF and a source/profile/toolchain receipt. The +ELF check requires executable ARM code at the upload address and checks +every allocated section against the raw bytes. ARM unwind tables are +discarded so they cannot displace the entry instruction. + +`src/orchard/image/cascadia.py` assembles one board's payload (also by file +path: `--profile --loader [--loader-elf] --kernel --dtb --initramfs +--output`). Kernel bytes stay unchanged. The DTB's `/chosen` +`linux,initrd-start`/`-end`, placeholders in the board's tree, are set in +place to the initramfs copy; nothing else in the tree changes, its boot +arguments included. + +The layout is a checked loader followed by padding to 32 KiB, the zImage, +the DTB and the initramfs, each data address aligned to eight bytes. The +loader disables the profile's watchdog, then checks its configuration +checksum and bounds; a rejected image stays stopped for inspection. It +copies the initramfs to the profile's `initrd_address` while iBEC's MMU and +caches are still on, then cleans the caches, turns the MMU off and enters +the zImage with the DTB in `r2`. This is the form Project Cascadia's own +P105 boot used (`linux_boot.c` `copy_overlay`, `assemble.py` +`OVERLAY_PHYS = 0x88000000`). The fixed loader stack uses the gap below +offset `0x7000`; code must finish below `0x6000`, and the configuration +starts at `0x7f00`. + +The zImage must carry the v6.12 ARM size table and end on an eight-byte +boundary. Its DTB follows immediately so the appended-DTB decompressor +(`CONFIG_ARM_APPENDED_DTB`) can relocate it. Assembly bounds the inflated +kernel together with the relocated decompressor's workspace below the +initramfs destination; unknown table formats and overlapping layouts are +refused. The receipt describes every byte range and its hash. + +Upstream Cascadia also built its runtime into the kernel +(`CONFIG_INITRAMFS_SOURCE`) and copied only machine-local files this way. +orchard copies its whole initrd plus the boot's add-on +(`src/orchard/install_direct.py`), which works with either kernel: with an +initrd built into the kernel, the copy is unpacked over it. + +See `NOTICE` for source provenance. This producer contacts no device; the +payload has not been booted on hardware yet. diff --git a/boot/cascadia/build b/boot/cascadia/build new file mode 100755 index 00000000..10e647c6 --- /dev/null +++ b/boot/cascadia/build @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Build the ARM32 staging loader from source and check its executable entry.""" +import argparse +import json +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'src')) +from orchard import buildenv +from orchard.image.cascadia import digest, profile, verify_loader + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--profile', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + described = profile(args.profile) + out = args.output.resolve() + if out.is_symlink() or (out.exists() and (not out.is_dir() or any(out.iterdir()))): + parser.error('The loader output must be a new or empty directory') + out.mkdir(parents=True, exist_ok=True) + (out / 'cascadia-generated.h').write_text(''.join( + f'#define {name.upper()} {hex(value)}\n' for name, value in sorted(described.items()) if name != 'schema')) + template = (ROOT / 'boot/cascadia/linux-boot.ld.in').read_text() + (out / 'linux-boot.ld').write_text(template.replace('@LOAD_ADDRESS@', hex(described['load_address']))) + image = 'localhost/orchard-hardware-kernel-toolchain:bookworm' + buildenv.build(image, ROOT / 'kernel/Containerfile', ROOT / 'kernel') + buildenv.run(image, ['sh', '/src/boot/cascadia/compile', '/out'], + [(ROOT, '/src', True), (out, '/out', False)]) + binary, elf = (out / 'loader.bin').read_bytes(), (out / 'loader.elf').read_bytes() + verify_loader(binary, described, elf) + source = ROOT / 'boot/cascadia' + receipt = {'schema': 1, 'kind': 'cascadia-loader', 'architecture': 'arm', + 'loader_format': 'cascadia', 'profile_sha256': digest(args.profile.read_bytes()), + 'toolchain': buildenv.identity(image), + 'sources': {path.name: digest(path.read_bytes()) for path in sorted(source.iterdir()) if path.is_file()}, + 'loader': {'bytes': len(binary), 'sha256': digest(binary)}, + 'elf': {'bytes': len(elf), 'sha256': digest(elf)}, + 'entry': described['load_address'], 'hardware_boot_verified': False} + (out / 'loader.json').write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') + for name in ('linux_boot.o', 'linux_boot_start.o', 'cascadia-generated.h', 'linux-boot.ld'): + (out / name).unlink() + print(f'Loader: {out / "loader.bin"}') + + +if __name__ == '__main__': + main() diff --git a/boot/cascadia/compile b/boot/cascadia/compile new file mode 100755 index 00000000..c824c114 --- /dev/null +++ b/boot/cascadia/compile @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +# SPDX-License-Identifier: GPL-2.0-only +set -eu +out=$1 +src=${ORCHARD_SRC:-$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)} +clang-19 --target=armv7a-none-eabi -march=armv7-a -marm -mfloat-abi=soft \ + -ffreestanding -fno-builtin -fno-stack-protector -fno-unwind-tables \ + -fno-asynchronous-unwind-tables -Os -Wall -Wextra -Werror \ + -I "$out" -c "$src/boot/cascadia/linux_boot.c" -o "$out/linux_boot.o" +clang-19 --target=armv7a-none-eabi -march=armv7-a -marm -I "$out" \ + -c "$src/boot/cascadia/linux_boot_start.S" -o "$out/linux_boot_start.o" +ld.lld-19 --build-id=none -T "$out/linux-boot.ld" \ + "$out/linux_boot_start.o" "$out/linux_boot.o" -o "$out/loader.elf" +llvm-objcopy-19 -O binary "$out/loader.elf" "$out/loader.bin" diff --git a/boot/cascadia/linux-boot.ld.in b/boot/cascadia/linux-boot.ld.in new file mode 100644 index 00000000..aa0ddcbc --- /dev/null +++ b/boot/cascadia/linux-boot.ld.in @@ -0,0 +1,22 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +ENTRY(_start) +SECTIONS +{ + . = @LOAD_ADDRESS@; + .text : { + KEEP(*(.text.start)) + *(.text .text.*) + *(.rodata .rodata.*) + *(.data .data.*) + } + ASSERT(. <= @LOAD_ADDRESS@ + 0x6000, "loader overlaps its stack") + . = @LOAD_ADDRESS@ + 0x7f00; + .loader_config : { KEEP(*(.loader_config)) } + ASSERT(SIZEOF(.loader_config) == 68, "loader configuration changed") + .ARM.attributes 0 : { *(.ARM.attributes) } + /DISCARD/ : { + *(.comment) *(.note*) *(.eh_frame*) + *(.ARM.exidx*) *(.ARM.extab*) + } +} +ASSERT(_start == @LOAD_ADDRESS@, "loader entry differs from the upload address") diff --git a/boot/cascadia/linux_boot.c b/boot/cascadia/linux_boot.c new file mode 100644 index 00000000..45061372 --- /dev/null +++ b/boot/cascadia/linux_boot.c @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* ARM32 iBEC handoff. See NOTICE for the Cascadia source and local changes. */ +typedef unsigned int u32; +typedef unsigned char u8; + +#include "cascadia-generated.h" + +struct config { + u8 magic[4]; + u32 version, header_size, load_address, kernel_offset; + u32 dtb_offset, dtb_size, initrd_offset, initrd_size; + u32 initrd_address, image_size, watchdog_address, l2_address; + u32 ram_start, ram_end, initrd_limit, crc32; +}; + +/* The assembler fills the dynamic fields and checksum before upload. */ +__attribute__((section(".loader_config"), used)) +volatile const struct config cascadia_config = { + { 'C', 'B', '3', '2' }, 1, sizeof(struct config), LOAD_ADDRESS, + KERNEL_OFFSET, 0, 0, 0, 0, INITRD_ADDRESS, 0, WATCHDOG_ADDRESS, + L2_ADDRESS, RAM_START, RAM_END, INITRD_LIMIT, 0, +}; + +static int range(u32 start, u32 size, u32 first, u32 end) +{ + return start >= first && start < end && size && size <= end - start; +} + +static u32 checksum(void) +{ + volatile const u8 *p = (volatile const u8 *)&cascadia_config; + u32 crc = ~0u, i, bit; + for (i = 0; i < sizeof(struct config) - sizeof(u32); i++) { + crc ^= p[i]; + for (bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (0xedb88320u & (0u - (crc & 1))); + } + return ~crc; +} + +static u32 big_endian(volatile const u8 *p) +{ + return ((u32)p[0] << 24) | ((u32)p[1] << 16) | + ((u32)p[2] << 8) | p[3]; +} + +u32 cascadia_main(void) +{ + volatile const struct config *c = &cascadia_config; + volatile const u8 *dtb, *from; + volatile u8 *to; + u32 i; + + if (WATCHDOG_ADDRESS) { + volatile u32 *wdt = (volatile u32 *)WATCHDOG_ADDRESS; + for (i = 0; i < 4; i++) + wdt[i] = 0; + } + if (c->magic[0] != 'C' || c->magic[1] != 'B' || + c->magic[2] != '3' || c->magic[3] != '2' || + c->version != 1 || c->header_size != sizeof(struct config) || + c->load_address != LOAD_ADDRESS || c->kernel_offset != KERNEL_OFFSET || + c->watchdog_address != WATCHDOG_ADDRESS || c->l2_address != L2_ADDRESS || + c->ram_start != RAM_START || c->ram_end != RAM_END || + c->initrd_limit != INITRD_LIMIT || + checksum() != c->crc32) + return 0; + if (!range(LOAD_ADDRESS, c->image_size, RAM_START, RAM_END) || + c->dtb_offset < KERNEL_OFFSET || c->dtb_size < 40 || + c->dtb_offset >= c->image_size || + c->dtb_size > c->image_size - c->dtb_offset || + c->initrd_offset < c->dtb_offset + c->dtb_size || + c->initrd_offset >= c->image_size || + c->initrd_size > c->image_size - c->initrd_offset || + c->initrd_address != INITRD_ADDRESS || + !range(INITRD_ADDRESS, c->initrd_size, RAM_START, INITRD_LIMIT) || + INITRD_ADDRESS < LOAD_ADDRESS + c->image_size) + return 0; + dtb = (volatile const u8 *)(LOAD_ADDRESS + c->dtb_offset); + if (big_endian(dtb) != 0xd00dfeedu || big_endian(dtb + 4) != c->dtb_size) + return 0; + from = (volatile const u8 *)(LOAD_ADDRESS + c->initrd_offset); + to = (volatile u8 *)INITRD_ADDRESS; + for (i = 0; i < c->initrd_size; i++) + to[i] = from[i]; + return LOAD_ADDRESS + c->dtb_offset; +} diff --git a/boot/cascadia/linux_boot_start.S b/boot/cascadia/linux_boot_start.S new file mode 100644 index 00000000..1052e304 --- /dev/null +++ b/boot/cascadia/linux_boot_start.S @@ -0,0 +1,135 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Derived from Project Cascadia; see NOTICE. */ +#include "cascadia-generated.h" + + .arch armv7-a + .syntax unified + .arm + + .equ KERNEL_ENTRY, LOAD_ADDRESS + KERNEL_OFFSET + + .section .text.start, "ax" + .global _start + .global cascadia_main + .global linux_boot_jump +_start: + cpsid if + msr cpsr_c, #0xd3 + + ldr sp, =LOAD_ADDRESS + 0x7000 + bl cascadia_main + /* main validates the image and returns DTB physical address. */ + cmp r0, #0 + beq 1f + b linux_boot_jump +1: wfi + b 1b + +/* + * r0 = DTB physical address + * Tear down MMU/caches, then bx KERNEL_ENTRY with + * r0=0, r1=~0, r2=DTB + */ +linux_boot_jump: + mov r4, r0 /* save DTB */ + + bl v7_flush_dcache_all + bl l2x0_flush_all + + mrc p15, 0, r0, c1, c0, 0 + bic r0, r0, #(1 << 0) /* MMU */ + bic r0, r0, #(1 << 2) /* D-cache */ + bic r0, r0, #(1 << 12) /* I-cache */ + bic r0, r0, #(1 << 11) /* Z */ + mcr p15, 0, r0, c1, c0, 0 + isb + + mov r0, #0 + mcr p15, 0, r0, c7, c5, 0 + mcr p15, 0, r0, c7, c5, 6 + mcr p15, 0, r0, c8, c7, 0 + dsb + isb + + mov r0, #0 + mvn r1, #0 + mov r2, r4 + ldr r3, =KERNEL_ENTRY + bx r3 + +1: wfi + b 1b + +v7_flush_dcache_all: + push {r4-r11, lr} + dmb + mrc p15, 1, r0, c0, c0, 1 + ands r3, r0, #0x7000000 + mov r3, r3, lsr #23 + beq .Lfinished + mov r10, #0 +.Lloop_level: + add r2, r10, r10, lsr #1 + mov r1, r0, lsr r2 + and r1, r1, #7 + cmp r1, #2 + blt .Lskip_level + mcr p15, 2, r10, c0, c0, 0 + isb + mrc p15, 1, r1, c0, c0, 0 + and r2, r1, #7 + add r2, r2, #4 + movw r4, #0x3ff + ands r4, r4, r1, lsr #3 + clz r5, r4 + movw r7, #0x7fff + ands r7, r7, r1, lsr #13 +.Lloop_way: + mov r9, r7 +.Lloop_set: + orr r11, r10, r4, lsl r5 + orr r11, r11, r9, lsl r2 + mcr p15, 0, r11, c7, c14, 2 + subs r9, r9, #1 + bge .Lloop_set + subs r4, r4, #1 + bge .Lloop_way +.Lskip_level: + add r10, r10, #2 + cmp r3, r10 + bgt .Lloop_level +.Lfinished: + mov r10, #0 + mcr p15, 2, r10, c0, c0, 0 + dsb + isb + pop {r4-r11, pc} + +l2x0_flush_all: + push {r0, r1, r2, lr} + ldr r0, =L2_ADDRESS + cmp r0, #0 + beq 2f + ldr r1, [r0] + lsr r2, r1, #6 + and r2, r2, #0x3f + cmp r2, #3 + cmpne r2, #2 + cmpne r2, #1 + bne 2f + ldr r1, [r0, #0x100] + tst r1, #1 + beq 2f + mov r1, #0xff00 + orr r1, r1, #0xff + str r1, [r0, #0x7fc] + ldr r2, =1000000 +1: ldr r1, [r0, #0x7fc] + tst r1, #1 + beq 2f + subs r2, r2, #1 + bne 1b +3: wfi + b 3b +2: dsb + pop {r0, r1, r2, pc} diff --git a/boot/initramfs/recovery/Containerfile.arm b/boot/initramfs/recovery/Containerfile.arm new file mode 100644 index 00000000..6169f6c6 --- /dev/null +++ b/boot/initramfs/recovery/Containerfile.arm @@ -0,0 +1,3 @@ +FROM docker.io/library/debian:bookworm +COPY install-toolchain /tmp/install-toolchain +RUN bash /tmp/install-toolchain --cross arm && rm /tmp/install-toolchain && rm -rf /var/lib/apt/lists/* diff --git a/boot/initramfs/recovery/build-tools b/boot/initramfs/recovery/build-tools index a5ea574e..426e4931 100755 --- a/boot/initramfs/recovery/build-tools +++ b/boot/initramfs/recovery/build-tools @@ -1,12 +1,20 @@ #!/usr/bin/env bash -# Acquire pinned public inputs and build static AArch64 recovery tools. +# Acquire pinned public inputs and build static recovery tools for the selected ARM architecture. set -euo pipefail # Recipes need only Python 3.11+ and its standard library; Orchard passes its own. python=${ORCHARD_PYTHON:-python3} +arch=arm64 +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) [[ $# -ge 2 ]] || { echo '--arch needs arm or arm64' >&2; exit 1; }; arch=$2; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done +case "$arch" in arm|arm64) ;; *) echo '--arch needs arm or arm64' >&2; exit 1 ;; esac here=$(cd "$(dirname "$0")" && pwd) -work="${OMA_RECOVERY_TOOLS_WORK:-$here/.work/tools}" +work="${OMA_RECOVERY_TOOLS_WORK:-$here/.work/tools-$arch}" mkdir -p "$work" -"$python" - "$here/upstream.lock" "$work" <<'PY' +"$python" - "$here/upstream.lock" "$work" "$arch" <<'PY' import hashlib import json import pathlib @@ -23,7 +31,8 @@ for component, filename in (('busybox', 'busybox.deb'), ('iw', 'iw.tar.xz'), ('zstd', 'zstd.tar.gz'), ('e2fsprogs', 'e2fsprogs.tar.gz')): - item = lock[component] + key = 'busybox_arm' if component == 'busybox' and sys.argv[3] == 'arm' else component + item = lock[key] target = work / filename if target.exists(): source = target.open('rb') @@ -49,9 +58,15 @@ for component, filename in (('busybox', 'busybox.deb'), pathlib.Path(temporary.name).rename(target) print(f'Verified {component}: {target}') PY -"$python" "$here/../../../src/orchard/buildenv.py" build --arch arm64 --file "$here/Containerfile" \ - --image localhost/orchard-hardware-recovery-tools:bookworm --context "$here" -"$python" "$here/../../../src/orchard/buildenv.py" run --arch arm64 \ - --bind "$work:/work:rw" --bind "$here:/recipe:ro" \ - --image localhost/orchard-hardware-recovery-tools:bookworm -- bash /recipe/build-tools-in-container +if [[ "$arch" == arm64 ]]; then + image=localhost/orchard-hardware-recovery-tools:bookworm containerfile=Containerfile build_arch=(--arch arm64) +else + # Cross-compiled on this machine's own architecture: Apple and most ARM64 hosts cannot run ARMv7. + image=localhost/orchard-hardware-recovery-tools-arm:bookworm containerfile=Containerfile.arm build_arch=() +fi +"$python" "$here/../../../src/orchard/buildenv.py" build ${build_arch[@]+"${build_arch[@]}"} \ + --file "$here/$containerfile" --image "$image" --context "$here" +"$python" "$here/../../../src/orchard/buildenv.py" run ${build_arch[@]+"${build_arch[@]}"} \ + --bind "$work:/work:rw" --bind "$here:/recipe:ro" --env "ORCHARD_RECOVERY_ARCH=$arch" \ + --image "$image" -- bash /recipe/build-tools-in-container echo "Recovery tools: $work/bin" diff --git a/boot/initramfs/recovery/build-tools-in-container b/boot/initramfs/recovery/build-tools-in-container index d6b04b48..7ae1f863 100755 --- a/boot/initramfs/recovery/build-tools-in-container +++ b/boot/initramfs/recovery/build-tools-in-container @@ -1,6 +1,9 @@ #!/bin/bash set -euo pipefail work=${ORCHARD_WORK:-/work} +recipe=${ORCHARD_RECIPE:-/recipe} +arch=${ORCHARD_RECOVERY_ARCH:-arm64} +case "$arch" in arm) deb_arch=armhf ;; arm64) deb_arch=arm64 ;; *) echo 'Unsupported recovery architecture' >&2; exit 1 ;; esac [[ ! -e "$work/bin" ]] || { echo 'Tool output exists; choose a fresh work directory' >&2; exit 1; } build=$(mktemp -d "$work/build.XXXXXX") mkdir "$build/bin" @@ -10,15 +13,30 @@ export CC=musl-gcc # musl-gcc searches no glibc headers; give it the kernel UAPI headers only. kernel_headers="$build/kernel-headers" mkdir "$kernel_headers" -ln -s /usr/include/linux /usr/include/asm-generic "$kernel_headers/" -ln -s "/usr/include/$(gcc -dumpmachine)/asm" "$kernel_headers/asm" +host=() +if [[ $arch == arm && $(uname -m) != armv7* ]]; then + # ARMv7 on any other machine is cross-compiled (install-toolchain --cross arm): + # Apple and most ARM64 hosts cannot run 32-bit ARM code at all. + cat > "$build/arm-musl-gcc" <<'CC' +#!/bin/sh +exec arm-linux-gnueabihf-gcc "$@" -specs /usr/lib/arm-linux-musleabihf/musl-gcc.specs +CC + chmod 755 "$build/arm-musl-gcc" + export CC="$build/arm-musl-gcc" AR=arm-linux-gnueabihf-ar RANLIB=arm-linux-gnueabihf-ranlib + host=(--host=arm-linux-musleabihf) + ln -s /usr/arm-linux-gnueabihf/include/linux /usr/arm-linux-gnueabihf/include/asm-generic \ + /usr/arm-linux-gnueabihf/include/asm "$kernel_headers/" +else + ln -s /usr/include/linux /usr/include/asm-generic "$kernel_headers/" + ln -s "/usr/include/$(gcc -dumpmachine)/asm" "$kernel_headers/asm" +fi export CFLAGS="-Os -isystem $kernel_headers" tar -xjf "$work/dropbear.tar.bz2" -C "$build" ( cd "$build/dropbear-2026.94" printf '%s\n' '#define DROPBEAR_SVR_PASSWORD_AUTH 0' '#define DROPBEAR_SVR_PAM_AUTH 0' > localoptions.h - ./configure --enable-static --disable-zlib + ./configure ${host[@]+"${host[@]}"} --enable-static --disable-zlib make -j2 PROGRAMS='dropbear dropbearkey' cp dropbear dropbearkey "$build/bin/" ) @@ -29,7 +47,7 @@ libnl="$build/libnl" tar -xzf "$work/libnl.tar.gz" -C "$build" ( cd "$build/libnl-3.11.0" - ./configure --prefix="$libnl" --enable-static --disable-shared --disable-cli --disable-debug + ./configure ${host[@]+"${host[@]}"} --prefix="$libnl" --enable-static --disable-shared --disable-cli --disable-debug make -j2 make install ) @@ -65,24 +83,26 @@ tar -xJf "$work/iw.tar.xz" -C "$build" # The installer's writer and the tools that finish a restored root, on any system. tar -xzf "$work/zstd.tar.gz" -C "$build" -make -C "$build/zstd-1.5.7/lib" -j2 libzstd.a CC=musl-gcc ZSTD_LEGACY_SUPPORT=0 +make -C "$build/zstd-1.5.7/lib" -j2 libzstd.a CC="$CC" ZSTD_LEGACY_SUPPORT=0 $CC $CFLAGS -static -Wall -Wextra -pthread -I"$build/zstd-1.5.7/lib" -o "$build/bin/orchard-write" \ - "${ORCHARD_RECIPE:-/recipe}/orchard-write.c" "$build/zstd-1.5.7/lib/libzstd.a" + "$recipe/orchard-write.c" "$build/zstd-1.5.7/lib/libzstd.a" tar -xzf "$work/e2fsprogs.tar.gz" -C "$build" ( cd "$build/e2fsprogs-1.47.4" - ./configure -q LDFLAGS=-static --disable-nls --disable-fuse2fs --disable-e2initrd-helper --disable-uuidd + ./configure -q ${host[@]+"${host[@]}"} LDFLAGS=-static --disable-nls --disable-fuse2fs --disable-e2initrd-helper --disable-uuidd make -j2 libs make -j2 -C resize resize2fs make -j2 -C misc tune2fs cp resize/resize2fs misc/tune2fs "$build/bin/" ) +[[ $(dpkg-deb -f "$work/busybox.deb" Architecture) == "$deb_arch" ]] || { + echo 'BusyBox package architecture differs from the target' >&2; exit 1; +} + dpkg-deb -x "$work/busybox.deb" "$build/package" cp "$build/package/bin/busybox" "$build/bin/busybox" chmod 755 "$build/bin/"* -for tool in "$build/bin/"*; do - file "$tool" | grep -q 'statically linked' || { echo "$tool is not static" >&2; exit 1; } -done +python3 "$recipe/check-tools.py" --arch "$arch" "$build/bin/"* mv "$build/bin" "$work/bin" echo "Static recovery tools copied to $work/bin" diff --git a/boot/initramfs/recovery/check-tools.py b/boot/initramfs/recovery/check-tools.py new file mode 100644 index 00000000..5ff6eb61 --- /dev/null +++ b/boot/initramfs/recovery/check-tools.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Check the recovery tools' target ISA and static ELF linkage without running them.""" +import argparse +from pathlib import Path +import stat +import struct + + +TARGETS = {'arm': (1, 40), 'arm64': (2, 183)} + + +def validate(data, arch): + if arch not in TARGETS: + raise ValueError('Unsupported recovery architecture: ' + arch) + elf_class, machine = TARGETS[arch] + if len(data) < 16 or data[:7] != b'\x7fELF' + bytes((elf_class, 1, 1)): + raise ValueError('ELF class or byte order differs from target ' + arch) + header = '<16sHHIIIIIHHHHHH' if elf_class == 1 else '<16sHHIQQQIHHHHHH' + if len(data) < struct.calcsize(header): + raise ValueError('Incomplete ELF header') + fields = struct.unpack_from(header, data) + if fields[1] not in (2, 3) or fields[2] != machine or fields[3] != 1: + raise ValueError('ELF machine or executable type differs from target ' + arch) + if fields[8] != struct.calcsize(header): + raise ValueError('Invalid ELF header size') + if arch == 'arm' and (fields[7] & 0xff000000 != 0x05000000 or + fields[7] & 0x600 != 0x400): + raise ValueError('ARM recovery requires EABI5 hard-float executables') + segment = ' len(data): + raise ValueError('Invalid ELF program header table') + dynamic = [] + for index in range(count): + values = struct.unpack_from(segment, data, offset + index * stride) + kind = values[0] + start, size = (values[1], values[4]) if elf_class == 1 else (values[2], values[5]) + if start + size > len(data): + raise ValueError('ELF segment exceeds the file') + if kind == 3: + raise ValueError('Recovery tools must be static: PT_INTERP is present') + if kind == 2: + dynamic.append((start, size)) + entry = ' 64 * 1024 * 1024: + raise ValueError('Expected a bounded regular executable: ' + str(path)) + validate(path.read_bytes(), arch) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--arch', choices=TARGETS, required=True) + parser.add_argument('tools', nargs='+', type=Path) + args = parser.parse_args() + for path in args.tools: + try: + check(path, args.arch) + except (OSError, ValueError, struct.error) as error: + parser.exit(1, str(path) + ': ' + str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/boot/initramfs/recovery/install-toolchain b/boot/initramfs/recovery/install-toolchain index e0375165..6d6db131 100644 --- a/boot/initramfs/recovery/install-toolchain +++ b/boot/initramfs/recovery/install-toolchain @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -packages=(bison build-essential bzip2 ca-certificates dpkg file flex linux-libc-dev musl-tools pkg-config xz-utils) -if [[ ${1:-} == --packages ]]; then printf '%s\n' "${packages[@]}"; exit 0; fi +packages=(bison build-essential bzip2 ca-certificates dpkg file flex linux-libc-dev musl-tools pkg-config python3 xz-utils) +# --cross arm: also what cross-compiles the ARMv7 tools on another machine (build-tools-in-container). +cross=() +if [[ ${1:-} == --cross ]]; then + [[ ${2:-} == arm ]] || { echo '--cross takes arm' >&2; exit 2; } + cross=(gcc-arm-linux-gnueabihf linux-libc-dev-armhf-cross musl-dev:armhf) + shift 2 +fi +if [[ ${1:-} == --packages ]]; then printf '%s\n' "${packages[@]}" ${cross[@]+"${cross[@]}"}; exit 0; fi +[[ ${#cross[@]} == 0 ]] || dpkg --add-architecture armhf apt-get update -apt-get install -y --no-install-recommends "${packages[@]}" +apt-get install -y --no-install-recommends "${packages[@]}" ${cross[@]+"${cross[@]}"} diff --git a/boot/initramfs/recovery/upstream.lock b/boot/initramfs/recovery/upstream.lock index c0cd9e62..0a8e32a1 100644 --- a/boot/initramfs/recovery/upstream.lock +++ b/boot/initramfs/recovery/upstream.lock @@ -26,5 +26,9 @@ "e2fsprogs": { "url": "https://mirrors.edge.kernel.org/pub/linux/kernel/people/tytso/e2fsprogs/v1.47.4/e2fsprogs-1.47.4.tar.gz", "sha256": "da274408bebbfd13a5a2fc3cfc66e3ffff17c48534673aa67f88d49b99123b96" + }, + "busybox_arm": { + "url": "https://deb.debian.org/debian/pool/main/b/busybox/busybox-static_1.35.0-4+deb12u1+b1_armhf.deb", + "sha256": "2f68dbeaebeda49f3cde34ac421d5add85fb1c837ff7c4a4971945340d9d66ff" } } diff --git a/boot/initramfs/root/init b/boot/initramfs/root/init index 66008ab2..c804ca1d 100755 --- a/boot/initramfs/root/init +++ b/boot/initramfs/root/init @@ -26,6 +26,11 @@ for argument in $(cat /proc/cmdline); do esac done +# The USB gadget's modules, where the kernel has them as modules: without them +# only USB is missing, so the boot goes on. +[ ! -e /etc/orchard/usb-modules ] || for module in $(cat /etc/orchard/usb-modules); do + modprobe "$module" || echo "orchard initrd: could not load $module; no USB" +done for module in $(cat /etc/orchard/modules); do modprobe "$module" || stop "could not load $module" done diff --git a/ci/install-toolchains b/ci/install-toolchains index 46d193fd..04b84553 100644 --- a/ci/install-toolchains +++ b/ci/install-toolchains @@ -2,21 +2,25 @@ # Install the union of the local recipes' package lists in one APT transaction. set -euo pipefail here=$(cd "$(dirname "$0")/.." && pwd) -[[ $# -gt 0 ]] || { echo 'Select kernel, recovery and/or m1n1' >&2; exit 2; } +[[ $# -gt 0 ]] || { echo 'Select kernel, recovery, recovery-arm and/or m1n1' >&2; exit 2; } recipes=() rust=false +armhf=false for name in "$@"; do case "$name" in kernel) recipes+=("$here/kernel/install-toolchain");; recovery) recipes+=("$here/boot/initramfs/recovery/install-toolchain");; + # The ARMv7 tools, cross-compiled: the recovery recipe's packages plus the armhf cross toolchain. + recovery-arm) recipes+=("$here/boot/initramfs/recovery/install-toolchain --cross arm"); armhf=true;; m1n1) recipes+=("$here/boot/m1n1/install-toolchain"); rust=true;; *) echo "Unknown toolchain: $name" >&2; exit 2;; esac done list=$(mktemp) trap 'rm -f "$list"' EXIT -for recipe in "${recipes[@]}"; do bash "$recipe" --packages >> "$list"; done +for recipe in "${recipes[@]}"; do bash $recipe --packages >> "$list"; done mapfile -t packages < <(sort -u "$list") +if "$armhf"; then dpkg --add-architecture armhf; fi apt-get update apt-get install -y --no-install-recommends "${packages[@]}" if "$rust"; then bash "$here/boot/m1n1/install-toolchain" --rust-only; fi diff --git a/devices/p105/cascadia.toml b/devices/p105/cascadia.toml new file mode 100644 index 00000000..842931b5 --- /dev/null +++ b/devices/p105/cascadia.toml @@ -0,0 +1,16 @@ +# P105 handoff addresses for the Cascadia loader (boot/cascadia), which iBEC +# starts at load_address. The initramfs goes where Project Cascadia's own P105 +# boot put its extra archive (assemble.py OVERLAY_PHYS 0x88000000, at most +# 128 MiB), which is also where the board DTB's /chosen initrd placeholders +# point: past the decompressed kernel, inside what iBEC maps. +# Interim home: where boot addresses finally live is part of the device-data +# and launcher decision; nothing else in orchard repeats them. +schema = 1 +load_address = 0x80000000 +kernel_offset = 0x8000 +watchdog_address = 0x0f103020 +l2_address = 0x3e000000 +ram_start = 0x80000000 +ram_end = 0xa0000000 +initrd_address = 0x88000000 +initrd_limit = 0x90000000 diff --git a/docs/distribution-contract.md b/docs/distribution-contract.md index 082f604f..f10cfacb 100644 --- a/docs/distribution-contract.md +++ b/docs/distribution-contract.md @@ -21,16 +21,28 @@ without orchard takes it as an argument. | Command | Does | Files | |---|---|---| -| `orchard.inputs --output FILE [--platform P …] [--kernel-dir P=DIR …] [--m1n1 FILE] [--pongo FILE] [--tools DIR] [--kernel-tree DIR]` (`ORCHARD_KERNEL_TREE` too) | resolves what the build takes finished | `FILE`: per platform its kernel bundle and boards (board → DTB), the shared m1n1, Pongo and static tools, orchard's revision, and under `produced` how each was got (built: its commands; reused from the cache; downloaded from a nightly: tag, asset, sha256); paths into orchard's cache | +| `orchard.inputs --output FILE [--platform P …] [--kernel-dir P=DIR …] [--m1n1 FILE] [--pongo FILE] [--tools DIR] [--loader BOARD=FILE …] [--kernel-tree DIR]` (`ORCHARD_KERNEL_TREE` too) | resolves what the build takes finished | `FILE`: per platform its kernel bundle and boards (board → DTB), the shared m1n1, Pongo and static tools, orchard's revision, and under `produced` how each was got (built: its commands; reused from the cache; downloaded from a nightly: tag, asset, sha256); paths into orchard's cache. With 32-bit platforms (`--platform apple-armv7`): `"architecture": "arm"`, ARMv7 tools under `shared`, and per platform `loaders` (board → its Cascadia loader) instead of m1n1 and Pongo; one document never mixes architectures | | `orchard.image.platform P --output DIR --kernel-dir BUNDLE --busybox FILE [--receipt FILE]` | the platform layer of installed systems | `DIR/platform-P.tar.gz`, its receipt | -| `orchard.image.platforms_index DIR (--release TAG [--platform P …] \| --inputs FILE \| --kernels DIR --tools DIR --commit SHA)` | what a build takes from orchard: every arm64 platform layer, Pongo, m1n1, `install-tools.tar.gz`, and `platforms.toml` listing them; from a published orchard build (`--release`, which `orchard.image.publish` then links instead of copying), or built here | `DIR/*` | +| `orchard.image.platforms_index DIR (--release TAG [--platform P …] \| --inputs FILE \| --kernels DIR --tools DIR --commit SHA) [--architecture arm64\|arm] [--loader BOARD=FILE …]` | what a build takes from orchard: every arm64 platform layer, Pongo, m1n1, `install-tools.tar.gz`, and `platforms.toml` listing them; from a published orchard build (`--release`, which `orchard.image.publish` then links instead of copying), or built here. For 32-bit roots (`--architecture arm`, or an inputs document of them): `platforms-armv7.toml` next to it, with the ARMv7 layers, `install-tools-armv7.tar.gz` and per board `cascadia-BOARD.bin` | `DIR/*` | | `orchard.image.root_image ROOT.tar.* --output DIR` | a root as the block image the installer writes; needs Podman, or root on a native build | `DIR/root.blocks.zst`, `DIR/root.map.json`, `DIR/root-image.json` (the manifest's `[root]`) | | `orchard.image.recovery --dtb FILE … --loader FILE [--logo FILE] --kernel FILE --modules-dir DIR --busybox --dropbear --dropbearkey --wpa-supplicant --wpa-cli --iw FILE --base FILE --base-receipt FILE --root-receipt FILE --output FILE [--verbose]` | a platform's RAM recovery | the image and `IMAGE.json` | | `orchard.installer release DIR [--installer-url URL] [--pongo FILE] [--m1n1 FILE]` | the installer of a release | `DIR/install.sh`, `DIR/orchard-install.pyz` | | `orchard.manifests FILE … [--verify-local]` | checks release manifests | exit status | | `orchard.image.publish --distro NAME --tag TAG --release-dir DIR --output DIR` | the hosted release | `DIR` | | `$ORCHARD/src/orchard/image/assemble.py` (by path) | an m1n1 payload | see [ram-development.md](ram-development.md#assembling-by-hand) | +| `$ORCHARD/src/orchard/image/cascadia.py --profile devices/BOARD/cascadia.toml --loader FILE [--loader-elf FILE] --kernel zImage --dtb FILE --initramfs FILE --output FILE` (by path) | a 32-bit board's Cascadia payload | the image and `IMAGE.json` | | `$ORCHARD/src/orchard/buildenv.py` (by path; standard library only) | the build container or native build of a recipe: `build`, `run`, and as a module `native()`, `build()`, `run()`, `identity()` | — | Changing one of these is a contract change: say so in the changelog, and update both distributions in the same push set. + +## 32-bit roots + +A distribution that builds an ARMv7 root takes `platforms-armv7.toml` +instead of `platforms.toml` and writes a release manifest for it: the same +`root`, `install_tools` (the ARMv7 one) and `platforms`, with +`boot_method = "recovery-autogo"`, no `pongo`, `m1n1` or `logo`, and a +`cascadia` table naming each board's loader (`[cascadia] p105 = {…}`, the +index's `loaders.cascadia`). orchard assembles a board's payload from that +loader, the platform layer and its own device data, so the release says +nothing about boot addresses. diff --git a/platforms/apple-armv7.toml b/platforms/apple-armv7.toml index 61510ff2..05dba21e 100644 --- a/platforms/apple-armv7.toml +++ b/platforms/apple-armv7.toml @@ -15,3 +15,8 @@ config = "kernel/configs/apple-armv7/config" defconfig = "orchard_applearmv7_defconfig" # the same config in OrchardLinux/linux branch = "armv7" # its branch in OrchardLinux/linux loader = "cascadia" # the payload format that boots this kernel + +[root] +# H2FMI is built in. The installed NAND backend supplies the early root +# setup; the arm64 platforms' NVMe modules do not apply to this platform. +storage_modules = [] diff --git a/schemas/v2/install.schema.json b/schemas/v2/install.schema.json index c73466b7..62362ce2 100644 --- a/schemas/v2/install.schema.json +++ b/schemas/v2/install.schema.json @@ -146,6 +146,18 @@ "m1n1": { "$ref": "#/$defs/receipt" }, + "cascadia": { + "type": "object", + "description": "32-bit releases: per board, the Cascadia loader that a patched iBEC starts in place of Pongo and m1n1. orchard assembles the board's payload from it, the platform layer and its own device data.", + "minProperties": 1, + "propertyNames": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "additionalProperties": { + "$ref": "#/$defs/receipt" + } + }, "install_tools": { "$ref": "#/$defs/receipt" } @@ -184,7 +196,7 @@ } }, { - "description": "A release installs from a block image (root.format), or has a recovery per platform.", + "description": "A release installs from a block image (root.format), or has a recovery per platform. A block-image release names its loader: m1n1 (arm64 roots) or per board the Cascadia loader (32-bit roots).", "if": { "properties": { "root": { @@ -203,8 +215,19 @@ } }, "required": [ - "m1n1", "install_tools" + ], + "oneOf": [ + { + "required": [ + "m1n1" + ] + }, + { + "required": [ + "cascadia" + ] + } ] }, "else": { diff --git a/src/orchard/build.py b/src/orchard/build.py index b57711c5..150838ba 100644 --- a/src/orchard/build.py +++ b/src/orchard/build.py @@ -490,8 +490,8 @@ def has_loader_patches(hardware): # What orchard's m1n1 (the quiet one every device boots) is built from. M1N1_INPUTS = ['boot/m1n1', 'boot/quiet/m1n1.patch', 'src/orchard/buildenv.py'] TOOL_INPUTS = ['boot/initramfs/recovery/' + name for name in - ('build-tools', 'build-tools-in-container', 'Containerfile', - 'install-toolchain', 'upstream.lock', 'orchard-write.c')] + ['src/orchard/buildenv.py'] + ('build-tools', 'build-tools-in-container', 'Containerfile', 'Containerfile.arm', + 'install-toolchain', 'upstream.lock', 'check-tools.py', 'orchard-write.c')] + ['src/orchard/buildenv.py'] def recipe_work(components, recipe, key): @@ -508,30 +508,62 @@ def recipe_work(components, recipe, key): return work -def _prepare_tools(hardware, components): +def _prepare_tools(hardware, components, architecture='arm64'): """The hardware's static recovery tools (BusyBox, Dropbear, Wi-Fi tools). Caller holds the lock.""" + if architecture not in ('arm', 'arm64'): + raise ValueError('Unsupported recovery architecture: ' + architecture) + name = 'recovery-tools' if architecture == 'arm64' else 'recovery-tools-' + architecture + key = recipe_key(hardware, TOOL_INPUTS) + if architecture != 'arm64': + key = hashlib.sha256((key + ':' + architecture).encode()).hexdigest() def produce_tools(stage): - work = recipe_work(components, 'recovery-tools', recipe_key(hardware, TOOL_INPUTS)) + work = recipe_work(components, name, key) if not (work / 'bin/busybox').exists(): - command(hardware / 'boot/initramfs/recovery/build-tools', + command(hardware / 'boot/initramfs/recovery/build-tools', '--arch', architecture, env={**os.environ, 'OMA_RECOVERY_TOOLS_WORK': str(work)}) + command(sys.executable, hardware / 'boot/initramfs/recovery/check-tools.py', + '--arch', architecture, *(work / 'bin' / name for name in + ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw', + 'orchard-write', 'resize2fs', 'tune2fs'))) for tool in (work / 'bin').iterdir(): if tool.is_file() and not tool.is_symlink(): shutil.copyfile(tool, stage / tool.name) (stage / tool.name).chmod(0o755) - return cached_component(components, 'recovery-tools', - lambda: recipe_key(hardware, TOOL_INPUTS), produce_tools) + return cached_component(components, name, lambda: key, produce_tools) def prepare_tools(hardware=None, cache=None, *, device): """The directory of static recovery tools for RAM images, built or reused.""" from . import published hardware, cache = _build_context(hardware, cache, device) - tools = published.tools(hardware, cache) + architecture = kernels.descriptor(hardware, device)['arch'] + tools = published.tools(hardware, cache) if architecture == 'arm64' else None if tools is not None: return tools with component_lock(cache): - return _prepare_tools(hardware, cache / 'components-v2') + if architecture == 'arm64': + return _prepare_tools(hardware, cache / 'components-v2') + return _prepare_tools(hardware, cache / 'components-v2', architecture) + + +def prepare_payload_loader(hardware=None, cache=None, *, device): + """Build the loader format named by the device's platform descriptor.""" + hardware, cache = _build_context(hardware, cache, device) + kind = kernels.descriptor(hardware, device)['loader'] + components = cache / 'components-v2' + with component_lock(cache): + if kind == 'm1n1': + return _prepare_m1n1(hardware, components, 'apple') + if kind != 'cascadia': + raise ValueError('Unsupported payload loader: ' + kind) + from .hardware import payload_profile + profile = payload_profile(device, 'cascadia', hardware) + inputs = ['boot/cascadia', 'src/orchard/image/cascadia.py', str(profile.relative_to(hardware)), + 'src/orchard/buildenv.py'] + def produce(stage): + command(hardware / 'boot/cascadia/build', '--profile', profile, '--output', stage) + return cached_component(components, 'loader-cascadia-' + device, + lambda: recipe_key(hardware, inputs), produce) / 'loader.bin' RELEASES = 'https://github.com/OrchardLinux/orchard/releases/download' @@ -630,19 +662,24 @@ def prepare_components(hardware=None, cache=None, *, device, kernel=None, if kernel is not None and kernel_tree is not None: raise ValueError('Give a staged kernel (--kernel-dir) or a kernel tree (--kernel-tree), not both') tree = kernels.check_tree(hardware, device, kernel_tree) if kernel_tree is not None else None + described = kernels.descriptor(hardware, device) + if pongo is not None and described['loader'] == 'cascadia': + raise ValueError('Cascadia payloads use recovery-autogo firmware stages') + if loader is None and described['loader'] == 'cascadia': + loader = prepare_payload_loader(hardware, cache, device=device) if loader is None and not has_loader_patches(hardware): raise ValueError(f'Missing loader patches for {device}; add a loader recipe or supply --loader FILE') with component_lock(cache): - if pongo is None: + if pongo is None and described['loader'] == 'm1n1': pongo = _prepare_pongo(hardware, components) if kernel is None: kernel = _prepare_kernel(hardware, cache, device, tree) if busybox is None: - busybox = _prepare_tools(hardware, components) / 'busybox' + busybox = _prepare_tools(hardware, components, described['arch']) / 'busybox' if loader is None: # One m1n1 starts every Apple device; the board only picks its DTB. loader = _prepare_m1n1(hardware, components, 'apple') - return {name: Path(path).resolve() for name, path in + return {name: Path(path).resolve() if path is not None else None for name, path in dict(kernel=kernel, busybox=busybox, loader=loader, pongo=pongo).items()} diff --git a/src/orchard/buildenv.py b/src/orchard/buildenv.py index 332c7e04..a4c1d763 100644 --- a/src/orchard/buildenv.py +++ b/src/orchard/buildenv.py @@ -2,7 +2,7 @@ Recipes address inputs through ORCHARD_* path variables. Native mode provides real paths; local mode provides the corresponding container mount paths. Native -mode is not a sandbox and is only for trusted, disposable Linux ARM64 workers. +mode is not a sandbox and is only for trusted, disposable Linux ARM workers. """ import argparse import os @@ -17,8 +17,8 @@ def native(): if value not in ('0', '1'): raise ValueError('ORCHARD_BUILD_NATIVE must be 0 or 1') if value == '1': - if platform.system() != 'Linux' or platform.machine() not in ('aarch64', 'arm64'): - raise ValueError('Native builds require Linux ARM64') + if platform.system() != 'Linux' or platform.machine().lower() not in ('aarch64', 'arm64', 'armv7l', 'armv7', 'armhf'): + raise ValueError('Native builds require Linux ARM64 or ARMv7') if not os.environ.get('ORCHARD_BUILD_ENV_ID'): raise ValueError('Native builds require ORCHARD_BUILD_ENV_ID') return True @@ -26,20 +26,31 @@ def native(): # Podman's names for host machines it can build for. -HOST_ARCHES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64'} +HOST_ARCHES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64', + 'armv7l': 'arm', 'armv7': 'arm', 'armhf': 'arm'} + + +def architecture_arguments(arch): + # OCI's ARM architecture needs a variant; plain arm can resolve to ARMv5. + return ['--platform', 'linux/arm/v7'] if arch == 'arm' else (['--arch', arch] if arch else []) + + +def check_native_architecture(arch): + if arch is not None and HOST_ARCHES.get(platform.machine().lower()) != arch: + raise ValueError(f'Native target {arch} differs from host architecture ' + f'{HOST_ARCHES.get(platform.machine().lower(), platform.machine())}') def build(image, containerfile, context, arch=None): if native(): - if arch not in (None, 'arm64'): - raise ValueError('Native builds require ARM64 inputs') + check_native_architecture(arch) print('Using provisioned native environment: ' + identity(image), flush=True) else: # Always name the architecture: recipes share base images such as # debian:bookworm, and an arm64 recipe re-points that local tag, which # a host-architecture build would otherwise silently inherit. arch = arch or HOST_ARCHES.get(platform.machine().lower()) - argv = ['podman', 'build'] + (['--arch', arch] if arch else []) + argv = ['podman', 'build', *architecture_arguments(arch)] subprocess.run([*argv, '-f', str(containerfile), '-t', image, str(context)], check=True) @@ -63,8 +74,7 @@ def translate(value, mounts): def run(image, command, mounts=(), *, env=None, cwd=None, root=False, network=False, arch=None, memory=None): """MEMORY: keep ORCHARD_WORK in memory of this size where a container runs it (e.g. '12g').""" if native(): - if arch not in (None, 'arm64'): - raise ValueError('Native builds require ARM64 inputs') + check_native_architecture(arch) with tempfile.TemporaryDirectory(prefix='orchard-recipe-') as temporary: work, tmp = Path(temporary) / 'work', Path(temporary) / 'tmp' work.mkdir() @@ -77,8 +87,7 @@ def run(image, command, mounts=(), *, env=None, cwd=None, root=False, network=Fa env={**os.environ, **paths, **extra}, check=True) else: argv = ['podman', 'run', '--rm', '--userns', 'keep-id:uid=0,gid=0' if root else 'keep-id'] - if arch: - argv += ['--arch', arch] + argv += architecture_arguments(arch) if not network: argv += ['--network', 'none'] if memory: diff --git a/src/orchard/hardware.py b/src/orchard/hardware.py index e4d5b010..9eea2e88 100644 --- a/src/orchard/hardware.py +++ b/src/orchard/hardware.py @@ -59,6 +59,16 @@ def device_path(profile, root=None): return path +def payload_profile(profile, kind, root=None): + """The board-owned profile for a source-built payload loader.""" + if kind not in ('cascadia',): + raise ValueError('Unsupported payload profile: ' + str(kind)) + path = device_path(profile, root).with_name(kind + '.toml') + if not path.is_file() or path.is_symlink(): + raise ValueError(f'{profile} has no {kind} handoff profile') + return path + + def profiles(root=None): """Every device this hardware data describes.""" devices = Path(root or data_root()) / 'devices' diff --git a/src/orchard/image/cascadia.py b/src/orchard/image/cascadia.py new file mode 100644 index 00000000..afee424a --- /dev/null +++ b/src/orchard/image/cascadia.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Assemble an ARM32 iBEC payload: the Cascadia loader, the zImage with its DTB, and an initramfs. + +The loader occupies the first 32 KiB. Its checked configuration tells it +where the initramfs is; it copies it to the profile's initrd address before +it turns off the MMU and enters the unchanged zImage, as Project Cascadia's +linux_boot does. The DTB's /chosen linux,initrd-start/-end, placeholders in +the board's tree, are set in place to that copy. +This module uses only the standard library and also runs by file path. +""" +import argparse +import hashlib +import json +from pathlib import Path +import struct +import tomllib +import zlib + + +CONFIG_OFFSET = 0x7f00 +CONFIG = struct.Struct('<4s16I') +CONFIG_FIELDS = ('magic', 'version', 'header_size', 'load_address', 'kernel_offset', + 'dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', + 'initrd_address', 'image_size', 'watchdog_address', 'l2_address', + 'ram_start', 'ram_end', 'initrd_limit', 'crc32') +PROFILE_FIELDS = frozenset(('schema', 'load_address', 'kernel_offset', 'watchdog_address', + 'l2_address', 'ram_start', 'ram_end', 'initrd_address', 'initrd_limit')) +FDT_HEADER = struct.Struct('>10I') + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def aligned(value, size=8): + return (value + size - 1) & -size + + +def profile(path): + return validate_profile(tomllib.loads(Path(path).read_text())) + + +def validate_profile(result): + if set(result) != PROFILE_FIELDS or result['schema'] != 1: + raise ValueError('Expected the schema-1 Cascadia handoff profile') + if any(type(value) is not int or not 0 <= value <= 0xffffffff for value in result.values()): + raise ValueError('Handoff addresses must be unsigned 32-bit integers') + first, end = result['ram_start'], result['ram_end'] + base, target, limit = result['load_address'], result['initrd_address'], result['initrd_limit'] + if (result['kernel_offset'] != 0x8000 or base != first or base % 0x8000000 or + not first <= base < base + 0x8000 < target < limit <= end or target % 4096): + raise ValueError('Handoff memory ranges overlap or have invalid alignment') + if any(result[key] % 4 for key in ('watchdog_address', 'l2_address')): + raise ValueError('Handoff register addresses must be word aligned') + return result + + +def configuration(raw): + if len(raw) < CONFIG_OFFSET + CONFIG.size: + raise ValueError('Loader lacks its configuration record') + result = dict(zip(CONFIG_FIELDS, CONFIG.unpack_from(raw, CONFIG_OFFSET))) + if (result['magic'], result['version'], result['header_size']) != (b'CB32', 1, CONFIG.size): + raise ValueError('Unknown loader configuration format') + return result + + +def verify_loader(binary, described, elf=None): + """Reject metadata before ARM code and a loader built for another profile.""" + if (not CONFIG_OFFSET + CONFIG.size <= len(binary) <= 0x8000 or + binary[:4] != bytes.fromhex('c0000cf1')): + raise ValueError('Expected the ARM CPSID instruction at loader byte zero') + config = configuration(binary) + for field in PROFILE_FIELDS - {'schema'}: + if config[field] != described[field]: + raise ValueError('Loader configuration differs from the handoff profile: ' + field) + if any(config[field] for field in ('dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', + 'image_size', 'crc32')): + raise ValueError('Expected a pristine loader before payload assembly') + if elf is None: + return + if len(elf) < 52 or elf[:7] != b'\x7fELF\x01\x01\x01': + raise ValueError('Expected little-endian ELF32') + if struct.unpack_from(' len(elf): + raise ValueError('Invalid loader section table') + allocated = [] + for index in range(count): + _, kind, flags, address, source, length, *_ = struct.unpack_from('<10I', elf, offset + index * size) + if not flags & 2 or not length: + continue + relative = address - described['load_address'] + if (kind == 8 or relative < 0 or source + length > len(elf) or + relative + length > len(binary) or binary[relative:relative + length] != elf[source:source + length]): + raise ValueError('ELF allocated section differs from the raw loader') + if relative != CONFIG_OFFSET and relative + length > 0x6000: + raise ValueError('ELF section overlaps the loader stack or configuration') + allocated.append((relative, flags, length)) + if not allocated or min(allocated)[0] != 0 or not min(allocated)[1] & 4: + raise ValueError('Loader starts with non-executable metadata') + if not any(start == CONFIG_OFFSET and length == CONFIG.size for start, _, length in allocated): + raise ValueError('ELF lacks the configuration section') + + +def _cstring(data, offset, end): + if not 0 <= offset < end: + raise ValueError('FDT string offset exceeds its block') + stop = data.find(b'\0', offset, end) + if stop < 0: + raise ValueError('Unterminated FDT string') + try: + return data[offset:stop].decode('ascii'), stop + 1 + except UnicodeDecodeError as error: + raise ValueError('FDT names must be ASCII') from error + + +def read_fdt(data): + """Return a checked tree and reservation list, preserving every property.""" + if len(data) < FDT_HEADER.size: + raise ValueError('Truncated FDT header') + magic, total, tree_at, strings_at, reserve_at, version, last, cpu, strings_size, tree_size = FDT_HEADER.unpack_from(data) + if magic != 0xd00dfeed or total != len(data) or version < 17 or last > 17: + raise ValueError('Unsupported FDT header') + if (tree_at % 4 or reserve_at % 8 or + tree_size <= 0 or any(start < 40 or size < 0 or start + size > total + for start, size in ((tree_at, tree_size), (strings_at, strings_size))) or + not 40 <= reserve_at <= total - 16): + raise ValueError('Invalid FDT block bounds') + intervals = [(tree_at, tree_at + tree_size), (strings_at, strings_at + strings_size)] + if max(intervals[0][0], intervals[1][0]) < min(intervals[0][1], intervals[1][1]): + raise ValueError('FDT blocks overlap') + reservations, at = [], reserve_at + while True: + if at + 16 > total or any(max(at, start) < min(at + 16, end) for start, end in intervals): + raise ValueError('FDT reservation map is truncated or overlaps another block') + address, length = struct.unpack_from('>QQ', data, at) + at += 16 + if address == length == 0: + break + if not length or address + length > 1 << 64: + raise ValueError('Invalid FDT reservation') + reservations.append((address, length)) + names = data[strings_at:strings_at + strings_size] + at, end, stack, root, finished = tree_at, tree_at + tree_size, [], None, False + while at < end: + if at + 4 > end: + raise ValueError('Truncated FDT token') + token = struct.unpack_from('>I', data, at)[0] + at += 4 + if token == 1: + name, at = _cstring(data, at, end) + at = aligned(at, 4) + if at > end or len(stack) >= 128 or '/' in name: + raise ValueError('Invalid FDT node') + node = {'name': name, 'properties': {}, 'children': []} + if stack: + if not name or any(child['name'] == name for child in stack[-1]['children']): + raise ValueError('Duplicate or empty FDT child node') + stack[-1]['children'].append(node) + elif root is not None or name: + raise ValueError('Expected exactly one unnamed FDT root') + else: + root = node + stack.append(node) + elif token == 2: + if not stack: + raise ValueError('Unbalanced FDT end-node') + stack.pop() + elif token == 3: + if not stack or at + 8 > end or stack[-1]['children']: + raise ValueError('Invalid FDT property position') + length, name_at = struct.unpack_from('>II', data, at) + at += 8 + name, _ = _cstring(names, name_at, len(names)) + if not name or name in stack[-1]['properties'] or at + length > end: + raise ValueError('Invalid or repeated FDT property') + stack[-1]['properties'][name] = data[at:at + length] + at = aligned(at + length, 4) + elif token == 4: + continue + elif token == 9: + if stack or root is None or at != end: + raise ValueError('FDT ends before its nodes or has trailing tokens') + finished = True + break + else: + raise ValueError('Unknown FDT token') + if not finished: + raise ValueError('FDT lacks its end token') + return root, reservations, cpu + + +def check_compatible(data, expected): + """Bind a board profile to the supplied DTB before payload assembly.""" + tree, _, _ = read_fdt(data) + value = tree['properties'].get('compatible', b'') + if not value or not value.endswith(b'\0'): + raise ValueError('Cascadia DTB has no complete compatible list') + try: + observed = set(value[:-1].decode('ascii').split('\0')) + except UnicodeDecodeError as error: + raise ValueError('Cascadia DTB compatible is invalid') from error + if not observed.intersection(expected): + raise ValueError('Cascadia DTB compatible differs from the selected board') + + +def write_fdt(root, reservations=(), cpu=0): + names, offsets, tree = bytearray(), {}, bytearray() + def integer(value): + tree.extend(struct.pack('>I', value)) + def node(item): + integer(1) + tree.extend(item['name'].encode('ascii') + b'\0') + tree.extend(b'\0' * (-len(tree) % 4)) + for name, value in item['properties'].items(): + if name not in offsets: + offsets[name] = len(names) + names.extend(name.encode('ascii') + b'\0') + integer(3) + tree.extend(struct.pack('>II', len(value), offsets[name])) + tree.extend(value) + tree.extend(b'\0' * (-len(tree) % 4)) + for child in item['children']: + node(child) + integer(2) + node(root) + integer(9) + # A valid tree may have no properties; retain a one-byte strings block. + if not names: + names.append(0) + reserve = b''.join(struct.pack('>QQ', *row) for row in reservations) + b'\0' * 16 + tree_at = 40 + len(reserve) + strings_at = tree_at + len(tree) + return (FDT_HEADER.pack(0xd00dfeed, strings_at + len(names), tree_at, strings_at, + 40, 17, 16, cpu, len(names), len(tree)) + reserve + tree + names) + + +def initrd_properties(data): + """Offsets of the values of /chosen linux,initrd-start and -end, each one cell, in DATA.""" + read_fdt(data) # checked first, so the walk below can trust the structure + _, _, tree_at, strings_at, _, _, _, _, strings_size, tree_size = FDT_HEADER.unpack_from(data) + names = data[strings_at:strings_at + strings_size] + at, path, found = tree_at, [], {} + while at < tree_at + tree_size: + token = struct.unpack_from('>I', data, at)[0] + at += 4 + if token == 1: + name, at = _cstring(data, at, tree_at + tree_size) + path.append(name) + at = aligned(at, 4) + elif token == 2: + path.pop() + elif token == 3: + length, name_at = struct.unpack_from('>II', data, at) + at += 8 + name, _ = _cstring(names, name_at, len(names)) + if path == ['', 'chosen'] and name in ('linux,initrd-start', 'linux,initrd-end'): + if length != 4: + raise ValueError(f'/chosen {name} is {length} bytes, not one cell') + found[name] = at + at = aligned(at + length, 4) + elif token == 9: + break + if len(found) != 2: + raise ValueError('The DTB has no /chosen linux,initrd-start and -end to set; ' + 'the board\'s device tree must carry both as placeholders') + return found['linux,initrd-start'], found['linux,initrd-end'] + + +def set_initrd(data, start, size): + """DATA with /chosen linux,initrd-start/-end set in place to START and START + SIZE. + + Project Cascadia's assemble.py does the same: nothing else in the tree + moves or changes, its boot arguments included.""" + first, last = initrd_properties(data) + result = bytearray(data) + struct.pack_into('>I', result, first, start) + struct.pack_into('>I', result, last, start + size) + return bytes(result) + + +def decompression_bound(kernel, dtb_size, described): + """Conservatively bound the v6.12 ARM decompressor's relocated workspace. + + The zImage table describes its inflated image and BSS. The low upload + address overlaps the output, so reserve room for a relocated compressed + image as well as DTB growth, stack and malloc space above that output. + Unknown table formats are refused before a payload is written. + """ + if len(kernel) < 0x3c or len(kernel) % 8: + raise ValueError('zImage must end on an eight-byte boundary for its appended DTB') + if dtb_size > 0x100000: + raise ValueError('DTB exceeds the ARM decompressor workspace bound') + if struct.unpack_from(' len(kernel): + raise ValueError('zImage size table exceeds the kernel') + count, magic, size_at, bss_size, text_offset, malloc_size, end = struct.unpack_from('<7I', kernel, table_at) + if count != 6 or magic != 0x5a534c4b or end or size_at < 0x3c or size_at + 4 > len(kernel): + raise ValueError('Unsupported zImage size table') + inflated = struct.unpack_from(' 0x100000: + raise ValueError('Invalid zImage decompression parameters') + # head.S allows a DTB to grow by 50%, bounded at 1 MiB. Include the + # decompressor relocation/code/stack alignment with an extra 128 KiB. + dtb_workspace = min(max(aligned(dtb_size + dtb_size // 2), 0x8000), 0x100000) + bound = (described['ram_start'] + text_offset + inflated + bss_size + + aligned(len(kernel) + dtb_workspace + malloc_size + 0x20000, 4096)) + if bound > described['initrd_address']: + raise ValueError('Kernel decompression workspace overlaps the initramfs destination') + return bound + + +def assemble(loader, kernel, dtb, initramfs, described, *, elf=None): + """(image, receipt): LOADER, the unchanged zImage KERNEL with DTB appended, then INITRAMFS. + + The loader copies INITRAMFS to the profile's initrd address before it + turns off the MMU, and the DTB's /chosen says where it is.""" + validate_profile(described) + verify_loader(loader, described, elf) + if (len(kernel) < 0x30 or struct.unpack_from(' described['initrd_limit'] - described['initrd_address']: + raise ValueError('Initramfs exceeds its reserved destination') + tree = set_initrd(dtb, described['initrd_address'], len(initramfs)) + expanded_end = decompression_bound(kernel, len(tree), described) + kernel_at = described['kernel_offset'] + dtb_at = kernel_at + len(kernel) + initrd_at = aligned(dtb_at + len(tree)) + image_size = initrd_at + len(initramfs) + if described['load_address'] + image_size > described['initrd_address']: + raise ValueError('Uploaded payload overlaps the initramfs destination') + values = configuration(loader) + values.update(dtb_offset=dtb_at, dtb_size=len(tree), initrd_offset=initrd_at, + initrd_size=len(initramfs), image_size=image_size) + record = CONFIG.pack(*(values[field] for field in CONFIG_FIELDS)) + values['crc32'] = zlib.crc32(record[:-4]) + framed_loader = bytearray(loader) + framed_loader[CONFIG_OFFSET:CONFIG_OFFSET + CONFIG.size] = CONFIG.pack(*(values[field] for field in CONFIG_FIELDS)) + components, image = [], bytearray() + def append(name, payload): + if payload: + components.append({'name': name, 'offset': len(image), 'bytes': len(payload), 'sha256': digest(payload)}) + image.extend(payload) + append('loader', framed_loader) + append('loader-padding', b'\0' * (kernel_at - len(image))) + append('kernel', kernel) + append('kernel-padding', b'\0' * (dtb_at - len(image))) + append('dtb', tree) + append('dtb-padding', b'\0' * (initrd_at - len(image))) + append('initramfs', initramfs) + return bytes(image), {'schema': 1, 'architecture': 'arm', 'loader_format': 'cascadia', + 'image_sha256': digest(image), 'initramfs_encoding': 'cascadia-dtb', + 'components': components, 'load_address': described['load_address'], + 'kernel_entry': described['load_address'] + kernel_at, + 'dtb_address': described['load_address'] + dtb_at, + 'kernel_decompression_bound': expanded_end, + 'initramfs_address': described['initrd_address'], + 'input_loader_sha256': digest(loader), 'input_dtb_sha256': digest(dtb)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + for name in ('loader', 'kernel', 'dtb', 'initramfs', 'profile', 'output'): + parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--loader-elf', type=Path) + args = parser.parse_args() + receipt = args.output.with_suffix(args.output.suffix + '.json') + if args.output.exists() or receipt.exists(): + parser.error('Choose new image and receipt paths') + try: + data, document = assemble(args.loader.read_bytes(), args.kernel.read_bytes(), args.dtb.read_bytes(), + args.initramfs.read_bytes(), profile(args.profile), + elf=args.loader_elf.read_bytes() if args.loader_elf else None) + except (ValueError, KeyError) as error: + parser.error(str(error)) + document['profile_sha256'] = digest(args.profile.read_bytes()) + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('xb') as output: + output.write(data) + with receipt.open('x') as output: + json.dump(document, output, indent=2, sort_keys=True) + output.write('\n') + print(f'Built {args.output}; component receipt: {receipt}') + + +if __name__ == '__main__': + main() diff --git a/src/orchard/image/elf.py b/src/orchard/image/elf.py index 48c6fc14..914421f3 100644 --- a/src/orchard/image/elf.py +++ b/src/orchard/image/elf.py @@ -1,35 +1,100 @@ -"""Read ELF64 little-endian DT_NEEDED names without executing target binaries.""" +"""Inspect little-endian ELF32/ELF64 dependencies without executing target code.""" import struct +def _parts(data): + if data[:4] != b'\x7fELF': + return None + if len(data) < 16 or data[5:7] != b'\x01\x01' or data[4] not in (1, 2): + raise ValueError('Unsupported ELF encoding') + width = data[4] + header_size, program_size = (52, 32) if width == 1 else (64, 56) + if len(data) < header_size: + raise ValueError('Incomplete ELF header') + machine = struct.unpack_from(' len(data)): + raise ValueError('ELF program headers exceed the file') + segments = [] + for index in range(count): + position = offset + size * index + if width == 1: + kind, file_offset, virtual, _, file_size, _, _, _ = struct.unpack_from('<8I', data, position) + else: + kind, _, file_offset, virtual, _, file_size, _, _ = struct.unpack_from(' len(data): + raise ValueError('ELF segment exceeds the file') + segments.append((kind, virtual, file_offset, file_size)) + return width, machine, segments + + +def architecture(data): + """Return Orchard's architecture for an ARM executable or shared object.""" + parsed = _parts(data) + if parsed is None: + raise ValueError('Expected an ELF executable') + width, machine, _ = parsed + if width == 2 and machine == 183: + return 'arm64' + if width == 1 and machine == 40: + flags = struct.unpack_from('> 24 != 5 or not flags & 0x400 or flags & 0x200: + raise ValueError('ARM recovery requires EABI5 hard-float executables') + return 'arm' + raise ValueError('Unsupported recovery ELF architecture') + + def needed(data): - if data[:6] != b'\x7fELF\x02\x01': + parsed = _parts(data) + if parsed is None: return set() - offset = struct.unpack_from(' length: + raise ValueError('ELF string table exceeds its load segment') + length = string_size + result = set() + for name in names: + if name >= length: + raise ValueError('ELF dependency offset exceeds the string table') + end = data.find(b'\0', start + name, start + length) + if end < 0: + raise ValueError('Unterminated ELF dependency name') + try: + text = data[start + name:end].decode('ascii') + except UnicodeDecodeError as error: + raise ValueError('Non-ASCII ELF dependency name') from error + if not text or '/' in text or text in ('.', '..'): + raise ValueError('Invalid ELF dependency name') + result.add(text) + return result raise ValueError('ELF string table is outside load segments') diff --git a/src/orchard/image/platform.py b/src/orchard/image/platform.py index 76a5e4fb..b39ae7bb 100644 --- a/src/orchard/image/platform.py +++ b/src/orchard/image/platform.py @@ -17,6 +17,8 @@ import gzip import io import json +import re +import tomllib from pathlib import Path import stat import tarfile @@ -32,6 +34,10 @@ # nvme is PCIe storage; nvme-apple is the Apple ANS2 controller of the A11 # (d221). A platform may lack either one, but not both. STORAGE_MODULES = ('nvme', 'nvme-apple') +# The USB gadget the initrd and the install add-on set up (configfs, NCM and +# ACM). The arm64 kernels build it in; where a kernel has it as modules, the +# initrd carries them and loads them first, and a missing one costs USB only. +USB_MODULES = ('libcomposite', 'usb_f_ncm', 'usb_f_acm') # Where system/defaults may put files: vendor configuration under /usr/lib, # and the boards' ALSA UCM profiles where alsa-lib looks for them. DEFAULTS_UNDER = ('usr/lib/', 'usr/share/alsa/ucm2/') @@ -49,7 +55,7 @@ def boards(platform, hardware=None): ROOT_ARCH = 'arm64' # distributions build one arm64 root for every platform -def platforms(hardware=None): +def platforms(hardware=None, architecture=ROOT_ARCH): """Every platform with at least one described board whose kernel runs the shared arm64 root, sorted. A platform of another architecture (apple-armv7) has no platform layer until a root of its architecture exists.""" @@ -57,7 +63,7 @@ def platforms(hardware=None): from ..ram import boards_by_platform hardware = hardware or ROOT return sorted(platform for platform, boards in boards_by_platform(hardware).items() - if descriptor(hardware, sorted(boards)[0])['arch'] == ROOT_ARCH) + if architecture is None or descriptor(hardware, sorted(boards)[0])['arch'] == architecture) def kernel(platform, *, hardware=None, cache=None): @@ -79,7 +85,7 @@ def tools(hardware=None, cache=None, platform='apple-16k'): return prepare_tools(hardware, cache, device=sorted(boards(platform, hardware))[0]) / 'busybox' -def initrd(modules, release, busybox, *, hardware=None): +def initrd(modules, release, busybox, *, hardware=None, storage_modules=STORAGE_MODULES): """orchard's initrd for installed systems: BusyBox, the storage modules and their dependencies. MODULES is {path: (data, mode)} as module_files gives it. @@ -91,10 +97,11 @@ def initrd(modules, release, busybox, *, hardware=None): name, _, needs = line.partition(':') depends[name] = needs.split() by_name = {Path(name).name.split('.ko')[0].replace('_', '-'): name for name in depends} - loaded = [name for name in STORAGE_MODULES if name.replace('_', '-') in by_name] - if not loaded: - raise ValueError(f'Kernel bundle has no storage module: {", ".join(STORAGE_MODULES)}') - wanted, pending = [], [by_name[name.replace('_', '-')] for name in loaded] + loaded = [name for name in storage_modules if name.replace('_', '-') in by_name] + if storage_modules and not loaded: + raise ValueError(f'Kernel bundle has no storage module: {", ".join(storage_modules)}') + usb = [name for name in USB_MODULES if name.replace('_', '-') in by_name] + wanted, pending = [], [by_name[name.replace('_', '-')] for name in loaded + usb] while pending: name = pending.pop() if name not in wanted: @@ -106,6 +113,8 @@ def initrd(modules, release, busybox, *, hardware=None): entries['init'] = (stat.S_IFREG | 0o755, (hardware / 'boot/initramfs/root/init').read_bytes()) entries['bin/busybox'] = (stat.S_IFREG | 0o755, Path(busybox).read_bytes()) entries['etc/orchard/modules'] = (stat.S_IFREG | 0o644, ('\n'.join(loaded) + '\n').encode()) + if usb: + entries['etc/orchard/usb-modules'] = (stat.S_IFREG | 0o644, ('\n'.join(usb) + '\n').encode()) dependency_lines = [] for name in sorted(wanted): target = f'lib/modules/{release}/{name}' @@ -121,14 +130,31 @@ def initrd(modules, release, busybox, *, hardware=None): return output.read_bytes() +def storage_modules(hardware, platform): + """The platform's early-root modules; an empty list denotes built-in storage.""" + if not re.fullmatch(r'[a-z0-9][a-z0-9_-]*', platform): + raise ValueError('Invalid platform name') + settings = tomllib.loads((Path(hardware) / 'platforms' / (platform + '.toml')).read_text()) + modules = settings.get('root', {}).get('storage_modules', list(STORAGE_MODULES)) + if (not isinstance(modules, list) or + any(not isinstance(name, str) or not re.fullmatch(r'[a-zA-Z0-9_+-]+', name) for name in modules) or + len(modules) != len(set(modules))): + raise ValueError('Platform root.storage_modules must be distinct kernel module names') + return tuple(modules) + + def files(bundle, platform, board_dtbs, *, busybox, hardware=None): """(kernel release, {path: (data, mode)}) of PLATFORM's layer from a staged kernel BUNDLE.""" hardware = Path(hardware or ROOT) + from ..kernel import descriptor + described = descriptor(hardware, sorted(board_dtbs)[0]) bundle = Path(bundle) release, result = module_files(bundle / 'lib/modules') base = f'usr/lib/modules/{release}/' - result[base + 'vmlinuz'] = ((bundle / 'Image.gz').read_bytes(), 0o644) - result[base + 'initrd'] = (initrd(result, release, busybox, hardware=hardware), 0o644) + result[base + 'vmlinuz'] = ((bundle / described['image']).read_bytes(), 0o644) + modules_to_load = storage_modules(hardware, platform) + result[base + 'initrd'] = (initrd(result, release, busybox, hardware=hardware, + storage_modules=modules_to_load), 0o644) for dtb in sorted(set(board_dtbs.values())): path = bundle / 'dtbs' / (dtb + '.dtb') if path.is_symlink() or not path.is_file(): @@ -146,6 +172,9 @@ def files(bundle, platform, board_dtbs, *, busybox, hardware=None): result[name] = (path.read_bytes(), 0o755 if path.stat().st_mode & 0o111 else 0o644) description = {'schema': SCHEMA, 'platform': platform, 'kernel_release': release, 'boards': dict(sorted(board_dtbs.items()))} + if described['arch'] != ROOT_ARCH: + # arm64 layers stay as they were; another architecture says what it is. + description.update(architecture=described['arch'], image=described['image']) result['usr/lib/orchard/platform.json'] = ((json.dumps(description, indent=2) + '\n').encode(), 0o644) result['usr/lib/orchard/platform.SHA256SUMS'] = (checksums(result), 0o644) return release, result @@ -166,21 +195,35 @@ def write(layer, output): return expanded, len(layer) -def build(platform, output_directory, *, hardware=None, cache=None, bundle=None, busybox=None): +def build(platform, output_directory, *, hardware=None, cache=None, bundle=None, busybox=None, + root_architecture=None): """PLATFORM's layer as OUTPUT_DIRECTORY/platform-PLATFORM.tar.gz, plus its receipt. BUNDLE is a staged kernel bundle and BUSYBOX orchard's static BusyBox; without them they are built or reused from the cache. """ board_dtbs = boards(platform, hardware) + if root_architecture is not None: + from ..kernel import descriptor + architecture = descriptor(hardware or ROOT, sorted(board_dtbs)[0])['arch'] + if architecture != root_architecture: + raise ValueError(f'Platform {platform} architecture {architecture} differs from root {root_architecture}') bundle = Path(bundle) if bundle is not None else kernel(platform, hardware=hardware, cache=cache) busybox = Path(busybox) if busybox is not None else tools(hardware, cache, platform) + if root_architecture is not None: + from .elf import architecture as elf_architecture + if elf_architecture(busybox.read_bytes()) != root_architecture: + raise ValueError('Platform initrd BusyBox differs from the root architecture') release, layer = files(bundle, platform, board_dtbs, busybox=busybox, hardware=hardware) output = Path(output_directory) / f'platform-{platform}.tar.gz' expanded, members = write(layer, output) - return {'file': output.name, 'bytes': output.stat().st_size, 'sha256': sha(output), - 'compression': 'gzip', 'expanded_bytes': expanded, 'members': members, - 'kernel_release': release, 'boards': sorted(board_dtbs)} + receipt = {'file': output.name, 'bytes': output.stat().st_size, 'sha256': sha(output), + 'compression': 'gzip', 'expanded_bytes': expanded, 'members': members, + 'kernel_release': release, 'boards': sorted(board_dtbs)} + architecture = json.loads(layer['usr/lib/orchard/platform.json'][0]).get('architecture') + if architecture: + receipt['architecture'] = architecture + return receipt def main(): @@ -190,9 +233,12 @@ def main(): parser.add_argument('--kernel-dir', type=Path, help='a staged kernel bundle (default: build or reuse it)') parser.add_argument('--busybox', type=Path, help="orchard's static BusyBox (default: build or reuse it)") parser.add_argument('--receipt', type=Path, help="also write the layer's receipt (JSON) here") + parser.add_argument('--root-architecture', choices=('arm', 'arm64'), + help='require the platform to match this root architecture') args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) - receipt = build(args.platform, args.output, bundle=args.kernel_dir, busybox=args.busybox) + receipt = build(args.platform, args.output, bundle=args.kernel_dir, busybox=args.busybox, + root_architecture=args.root_architecture) if args.receipt: args.receipt.write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') print(json.dumps(receipt, indent=2)) diff --git a/src/orchard/image/platforms_index.py b/src/orchard/image/platforms_index.py index 04074db2..8ac9827d 100644 --- a/src/orchard/image/platforms_index.py +++ b/src/orchard/image/platforms_index.py @@ -5,6 +5,11 @@ install tools from these files, so it publishes only its root. Files are named relative to wherever platforms.toml was fetched from, because a nightly's tag isn't known while it is built. + +One index per root architecture: platforms.toml for arm64 roots, and next to +it platforms-armv7.toml for 32-bit ones (architecture = "arm": its layers, its +own install tools, and a Cascadia loader per board in place of Pongo and m1n1). +An arm64 distribution reads only platforms.toml and sees nothing new. """ import argparse import gzip @@ -23,6 +28,8 @@ INSTALL_TOOLS = ('orchard-write', 'dropbear', 'resize2fs', 'tune2fs') LOADERS = ('pongo.bin', 'm1n1.bin') REPOSITORY = 'OrchardLinux/orchard' +# The index of each root architecture (orchard's kernel ARCH names). +INDEX = {'arm64': 'platforms.toml', 'arm': 'platforms-armv7.toml'} def receipt(path): @@ -42,8 +49,10 @@ def write_install_tools(tools, output): def receipts(document): - """Every file platforms.toml names.""" - return [*document['loaders'].values(), document['install_tools'], *document['platforms'].values()] + """Every file an index names. The 32-bit index keeps its loaders by kind and board.""" + loaders = [entry for value in document['loaders'].values() + for entry in ([value] if 'sha256' in value else value.values())] + return [*loaders, document['install_tools'], *document['platforms'].values()] def build(directory, *, kernels, tools, commit, loaders=None): @@ -65,12 +74,39 @@ def build(directory, *, kernels, tools, commit, loaders=None): return document +def build_armv7(directory, *, kernels, tools, commit, loaders): + """Write the ARMv7 layers of KERNELS ({platform: bundle}), their install tools and platforms-armv7.toml. + + TOOLS is the directory of ARMv7 static tools; LOADERS ({board: loader.bin}) the boards' + Cascadia loaders, built for each board's addresses (devices/BOARD/cascadia.toml). + """ + cascadia = {} + for board, path in sorted(loaders.items()): + target = directory / f'cascadia-{board}.bin' + shutil.copyfile(path, target) + cascadia[board] = receipt(target) + layers = {name: platform.build(name, directory, bundle=bundle, busybox=tools / 'busybox', root_architecture='arm') + for name, bundle in kernels.items()} + write_install_tools(tools, directory / 'install-tools-armv7.tar.gz') + document = {'schema': 1, 'kind': 'orchard.platforms', 'architecture': 'arm', 'commit': commit, + 'loaders': {'cascadia': cascadia}, + 'install_tools': receipt(directory / 'install-tools-armv7.tar.gz'), + 'platforms': layers} + (directory / INDEX['arm']).write_text(toml(document)) + return document + + def from_inputs(directory, inputs): """platforms.toml and its files, from a document of orchard.inputs: this build's own kernels.""" with tempfile.TemporaryDirectory() as temporary: tools = Path(temporary) for name, path in inputs['shared']['tools'].items(): shutil.copyfile(path, tools / name) + if inputs.get('architecture') == 'arm': + return build_armv7(directory, kernels={name: Path(row['kernel']) for name, row in inputs['platforms'].items()}, + tools=tools, commit=(inputs.get('orchard') or {}).get('commit') or 'unknown', + loaders={board: Path(path) for row in inputs['platforms'].values() + for board, path in row['loaders'].items()}) return build(directory, kernels={name: Path(row['kernel']) for name, row in inputs['platforms'].items()}, tools=tools, commit=(inputs.get('orchard') or {}).get('commit') or 'unknown', loaders={name: inputs['shared'][name] for name in ('pongo', 'm1n1')}) @@ -85,19 +121,21 @@ def fetch(url, path, entry=None): raise ValueError(f'{url} differs from its receipt') -def from_release(directory, tag, platforms=None, *, repository=REPOSITORY): - """platforms.toml and its files from a published orchard build, by its immutable tag. +def from_release(directory, tag, platforms=None, *, repository=REPOSITORY, architecture='arm64'): + """An index and its files from a published orchard build, by its immutable tag. PLATFORMS keeps only those; the index then records base_url, so orchard.image.publish links - these files instead of hosting copies. + these files instead of hosting copies. ARCHITECTURE picks the index (INDEX). """ + name = INDEX[architecture] base = f'https://github.com/{repository}/releases/download/{tag}' with tempfile.TemporaryDirectory() as temporary: - index = Path(temporary) / 'platforms.toml' - fetch(base + '/platforms.toml', index) + index = Path(temporary) / name + fetch(base + '/' + name, index) document = tomllib.loads(index.read_text()) - if document.get('schema') != 1 or document.get('kind') != 'orchard.platforms': - raise ValueError(f'{tag} has no platforms.toml this orchard reads') + if (document.get('schema') != 1 or document.get('kind') != 'orchard.platforms' or + document.get('architecture', 'arm64') != architecture): + raise ValueError(f'{tag} has no {name} this orchard reads') if platforms: missing = sorted(set(platforms) - set(document['platforms'])) if missing: @@ -106,7 +144,7 @@ def from_release(directory, tag, platforms=None, *, repository=REPOSITORY): for entry in receipts(document): fetch(base + '/' + entry['file'], directory / entry['file'], entry) document['base_url'] = base - (directory / 'platforms.toml').write_text(toml(document)) + (directory / name).write_text(toml(document)) return document @@ -120,17 +158,31 @@ def main(): parser.add_argument('--tools', type=Path, help="with --kernels: orchard's static tools") parser.add_argument('--commit', help='with --kernels: the commit they were built from') parser.add_argument('--platform', action='append', help='with --release: only this platform; repeat') + parser.add_argument('--architecture', choices=sorted(INDEX), default='arm64', + help='with --release or --kernels: the root architecture whose index to write (default arm64)') + parser.add_argument('--loader', action='append', default=[], metavar='BOARD=FILE', + help="with --kernels --architecture arm: a board's Cascadia loader; repeat") args = parser.parse_args() args.directory.mkdir(parents=True, exist_ok=True) if args.release: - from_release(args.directory, args.release, args.platform) + from_release(args.directory, args.release, args.platform, architecture=args.architecture) elif args.inputs: from_inputs(args.directory, json.loads(args.inputs.read_text())) else: if not (args.tools and args.commit): parser.error('--kernels needs --tools and --commit') - build(args.directory, kernels={name: args.kernels / name / 'components/kernel' for name in platform.platforms()}, - tools=args.tools, commit=args.commit) + kernels = {name: args.kernels / name / 'components/kernel' + for name in platform.platforms(architecture=args.architecture)} + if args.architecture == 'arm64': + if args.loader: + parser.error('--loader is for --architecture arm') + build(args.directory, kernels=kernels, tools=args.tools, commit=args.commit) + else: + loaders = dict(value.split('=', 1) for value in args.loader if '=' in value) + if len(loaders) != len(args.loader) or not loaders: + parser.error('--architecture arm needs --loader BOARD=FILE for each board with a Cascadia loader') + build_armv7(args.directory, kernels=kernels, tools=args.tools, commit=args.commit, + loaders={board: Path(path) for board, path in loaders.items()}) if __name__ == '__main__': diff --git a/src/orchard/inputs.py b/src/orchard/inputs.py index 3d84b442..8f3b435c 100644 --- a/src/orchard/inputs.py +++ b/src/orchard/inputs.py @@ -17,6 +17,11 @@ take these paths as arguments. Without --platform: every platform a distribution root serves (arm64). +With 32-bit platforms (--platform apple-armv7) the document is that root's: +"architecture": "arm", ARMv7 tools under "shared", and per platform, instead +of m1n1 and Pongo, the Cascadia loader of each board that has one ("loaders", +board to file; built for the board's devices/BOARD/cascadia.toml). One +document is one root architecture: arm64 and ARMv7 platforms do not mix. Components given explicitly (--kernel-dir, --m1n1, --pongo, --tools, e.g. from a release's component bundles in CI) are used as they are: with all of them given, nothing is fetched or built. @@ -32,11 +37,13 @@ TOOLS = ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw', 'orchard-write', 'resize2fs', 'tune2fs') -def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared=None, kernel_tree=None): +def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared=None, kernel_tree=None, + loaders=None): """The inputs document for PLATFORMS (default: every arm64 platform). SHARED: {'m1n1', 'pongo', 'tools'} given explicitly; the rest is resolved. KERNEL_TREE: a developer's kernel tree to build every selected platform's kernel from. + LOADERS: ARMv7 only, {board: Cascadia loader} given explicitly. """ from .build import prepare_shared from .hardware import ROOT, revision @@ -44,7 +51,15 @@ def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared= hardware = Path(hardware or ROOT) explicit = explicit or {} known = platform.platforms(hardware) + armv7 = platform.platforms(hardware, architecture='arm') selected = list(platforms or known) + if selected and set(selected) <= set(armv7): + return _resolve_armv7(selected, explicit, hardware=hardware, cache=cache, shared=shared, + kernel_tree=kernel_tree, loaders=loaders) + if set(selected) & set(armv7): + raise ValueError('One document is one root architecture: choose arm64 or ARMv7 platforms, not both') + if loaders: + raise ValueError('Cascadia loaders are for ARMv7 platforms') unknown = sorted(set(selected) - set(known)) + sorted(set(explicit) - set(selected)) if unknown: raise ValueError(f'Unknown or unselected platform {", ".join(unknown)}; known: {", ".join(known)}') @@ -79,6 +94,63 @@ def bundle(name): } +def _bundle(name, explicit, hardware, cache, kernel_tree): + from .image import platform + if name in explicit: + return explicit[name] + if kernel_tree is not None: + from .build import prepare_kernel + return prepare_kernel(hardware, cache, device=sorted(platform.boards(name, hardware))[0], + kernel_tree=kernel_tree) + return platform.kernel(name, hardware=hardware, cache=cache) + + +def _resolve_armv7(selected, explicit, *, hardware, cache, shared, kernel_tree, loaders): + """The ARMv7 root's document: its kernels, its static tools and its boards' Cascadia loaders.""" + from .build import prepare_payload_loader, prepare_tools + from .hardware import payload_profile, revision + + def has_profile(board, hardware): + try: + payload_profile(board, 'cascadia', hardware) + except ValueError: + return False + return True + from .image import platform + unknown = sorted(set(explicit) - set(selected)) + if unknown: + raise ValueError(f'Unselected platform {", ".join(unknown)}') + given = {key: value for key, value in (shared or {}).items() if value is not None} + if set(given) - {'tools'}: + raise ValueError('ARMv7 platforms take no m1n1 or Pongo: their boards start the Cascadia loader') + boards = {name: platform.boards(name, hardware) for name in selected} + tools = Path(given['tools']) if 'tools' in given else \ + prepare_tools(hardware, cache, device=sorted(boards[selected[0]])[0]) + loaders = {board: Path(path) for board, path in (loaders or {}).items()} + strays = sorted(set(loaders) - {board for rows in boards.values() for board in rows}) + if strays: + raise ValueError(f'{", ".join(strays)}: not a board of {", ".join(selected)}') + rows = {} + for name in selected: + found = {} + for board in sorted(boards[name]): + if board in loaders: + found[board] = loaders[board] + elif has_profile(board, hardware): + found[board] = prepare_payload_loader(hardware, cache, device=board) + rows[name] = {'kernel': str(Path(_bundle(name, explicit, hardware, cache, kernel_tree)).resolve()), + 'boards': boards[name], 'loaders': {board: str(Path(path).resolve()) for board, path in found.items()}} + for path in (*(tools / name for name in TOOLS), *(Path(path) for row in rows.values() for path in row['loaders'].values())): + if not Path(path).is_file(): + raise ValueError(f'Missing component {path}') + try: + orchard = revision() + except ValueError: + orchard = {'commit': None, 'modified': None} + return {'schema': SCHEMA, 'orchard': orchard, 'architecture': 'arm', 'platforms': rows, + 'shared': {'tools': {name: str((tools / name).resolve()) for name in TOOLS}}} + + def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) parser.add_argument('--output', type=Path, required=True, help='the JSON document to write') @@ -88,6 +160,8 @@ def main(): parser.add_argument('--m1n1', type=Path, help="orchard's m1n1 to use as it is") parser.add_argument('--pongo', type=Path, help='the Pongo to use as it is') parser.add_argument('--tools', type=Path, help='a directory with the static tools to use as they are') + parser.add_argument('--loader', action='append', default=[], metavar='BOARD=FILE', + help="ARMv7: a board's Cascadia loader to use as it is; repeat") parser.add_argument('--kernel-tree', type=Path, help='build the kernels from this kernel tree ' '(default: ORCHARD_KERNEL_TREE, which orchard build --kernel-tree sets)') args = parser.parse_args() @@ -97,6 +171,12 @@ def main(): if not separator: parser.error('--kernel-dir takes PLATFORM=DIRECTORY') explicit[name] = Path(path) + loaders = {} + for value in args.loader: + board, separator, path = value.partition('=') + if not separator or not board or not path: + parser.error('--loader takes BOARD=FILE') + loaders[board] = Path(path) try: import os tree = args.kernel_tree or (Path(os.environ['ORCHARD_KERNEL_TREE']) if os.environ.get('ORCHARD_KERNEL_TREE') @@ -105,7 +185,8 @@ def main(): explain.start() try: document = resolve(args.platform, explicit, shared={'m1n1': args.m1n1, 'pongo': args.pongo, - 'tools': args.tools}, kernel_tree=tree) + 'tools': args.tools}, kernel_tree=tree, + loaders=loaders) finally: steps = explain.stop() except ValueError as error: diff --git a/src/orchard/install_direct.py b/src/orchard/install_direct.py index b5cfe000..eeaa0fbe 100644 --- a/src/orchard/install_direct.py +++ b/src/orchard/install_direct.py @@ -146,6 +146,43 @@ def payload(m1n1, logo, layer, devicetree, extra): INITRAMFS_MAGIC, struct.pack(' 128 * 1024 * 1024: raise ValueError('Payload exceeds the Pongo uploader limit (128 MiB)') - shutil.copyfile(pongo, work / 'pongo.bin') + if pongo is not None: + shutil.copyfile(pongo, work / 'pongo.bin') receipt = {'kind': 'orchard.ram-build', 'platform': platform, 'boards': sorted(boards), 'kernel_release': release, 'hardware_tested': False, # What `orchard run` needs to add a board's firmware layer. - 'assembly': {'loader': str(loader), 'kernel': str(kernel / 'Image.gz'), + 'assembly': {'loader': str(loader), 'kernel': str(kernel / described['image']), 'dtbs': [str(path) for path in dtbs], 'bootargs': bootargs}, - 'files': {name: digest(work / name) for name in ('linux.bin', 'pongo.bin')}, + 'files': {name: digest(work / name) for name in + (('linux.bin', 'pongo.bin') if pongo is not None else ('linux.bin',))}, 'sources': {str(p.resolve()): digest(p) for p in inputs}} - for name in ('linux.bin', 'linux.bin.json', 'pongo.bin', 'initramfs.cpio.gz'): + if profile is not None: + receipt['loader_format'] = 'cascadia' + receipt['boot_method'] = 'recovery-autogo' + receipt['assembly']['profile'] = str(profile) + for name in ('linux.bin', 'linux.bin.json', 'initramfs.cpio.gz', + *(('pongo.bin',) if pongo is not None else ())): os.replace(work / name, output / name) (output / 'build.json').write_text(json.dumps(receipt, indent=2) + '\n') print(f'Built {output / "linux.bin"}. No device was accessed.') return output -def assemble(bootargs, loader, dtbs, kernel, initramfs, output): +def assemble(bootargs, loader, dtbs, kernel, initramfs, output, *, profile=None): + if profile is not None: + # Cascadia keeps the board DTB's own boot arguments (image/cascadia.py). + (dtb,) = dtbs + elf = Path(loader).with_suffix('.elf') + command(sys.executable, IMAGE / 'cascadia.py', '--profile', profile, '--loader', loader, + *(['--loader-elf', elf] if elf.is_file() else []), '--dtb', dtb, '--kernel', kernel, + '--initramfs', initramfs, '--output', output) + return command(sys.executable, IMAGE / 'assemble.py', '--bootargs', bootargs, '--loader', loader, *(argument for dtb in dtbs for argument in ('--dtb', dtb)), '--kernel', kernel, '--initramfs', initramfs, '--output', output) @@ -218,7 +260,12 @@ def _build_directory(directory, *, hardware, cache, kernel, busybox, loader, pon hardware = Path(hardware).expanduser().resolve() platform = resolve_platform(directory, hardware, platform=platform, device=device) boards = boards_by_platform(hardware)[platform] - # Components are per platform; any of its boards selects them. + from .kernel import descriptor + if descriptor(hardware, platform)['loader'] == 'cascadia': + selected = build.resolve_device(directory, device) + if selected is None or selected not in boards: + raise ValueError('Select the Cascadia board with --device BOARD') + boards = {selected: boards[selected]} representative = sorted(boards)[0] cache = Path(cache).expanduser().resolve() if cache is not None else build.cache_root() output = directory / 'out' / platform @@ -302,10 +349,12 @@ def with_firmware(output, board, *, hardware=None, cache=None): image = work / 'image' image.mkdir() assemble(assembly['bootargs'], Path(assembly['loader']), [Path(path) for path in assembly['dtbs']], - Path(assembly['kernel']), combined, image / 'linux.bin') - shutil.copyfile(output / 'pongo.bin', image / 'pongo.bin') + Path(assembly['kernel']), combined, image / 'linux.bin', + profile=Path(assembly['profile']) if 'profile' in assembly else None) + if 'pongo.bin' in receipt['files']: + shutil.copyfile(output / 'pongo.bin', image / 'pongo.bin') layered = {**receipt, 'boards': [board], 'firmware': True, - 'files': {name: digest(image / name) for name in ('linux.bin', 'pongo.bin')}} + 'files': {name: digest(image / name) for name in receipt['files']}} layered.pop('overlay', None) layered['sources'] = {} (image / 'build.json').write_text(json.dumps(layered, indent=2) + '\n') @@ -318,7 +367,8 @@ def verify_ram(output): if 'device' in receipt and 'boards' not in receipt: # built before payloads were per platform receipt['boards'], receipt['platform'] = [receipt['device']], None if (receipt.get('kind') not in (None, 'orchard.ram-build') or not receipt.get('boards') or - set(receipt['files']) != {'linux.bin', 'pongo.bin'}): + set(receipt['files']) != ({'linux.bin'} if receipt.get('loader_format') == 'cascadia' else + {'linux.bin', 'pongo.bin'})): raise ValueError('Not a RAM-boot build') for name, expected in receipt['files'].items(): path = output / name diff --git a/src/orchard/releases.py b/src/orchard/releases.py index 5f2246c5..9bbd5456 100644 --- a/src/orchard/releases.py +++ b/src/orchard/releases.py @@ -186,6 +186,15 @@ def receipts_for(document, profile, purpose): """(platform, {role: receipt}) of what an install or a boot of this manifest needs.""" platform = platform_for(document, profile) files = document['platforms'][platform] + if direct(document) and 'cascadia' in document: + # A 32-bit release: the board's Cascadia loader instead of Pongo and m1n1. + if profile not in document['cascadia']: + raise ValueError(f'This release has no Cascadia loader for {profile}') + receipts = {'layer': files['layer'], 'cascadia': document['cascadia'][profile], + 'install_tools': document['install_tools']} + if purpose == 'install': + receipts.update(root=document['root'], map=document['root']['map']) + return platform, receipts receipts = {'pongo': document['pongo'], 'layer': files['layer']} if direct(document): receipts.update(m1n1=document['m1n1'], install_tools=document['install_tools']) @@ -225,7 +234,7 @@ def resolve(manifest, profile, progress=None, clock=None, purpose='install'): receipts = {'pongo': document['pongo'], 'recovery': document['boot']} labels = {'pongo': 'Pongo', 'recovery': 'Recovery', 'root': document.get('name', 'Linux'), 'layer': 'Kernel and modules', 'map': 'Image map', 'logo': 'Logo', - 'm1n1': 'm1n1', 'install_tools': 'Install tools'} + 'm1n1': 'm1n1', 'install_tools': 'Install tools', 'cascadia': 'Cascadia loader'} paths = {} with ThreadPoolExecutor(max_workers=len(receipts)) as pool: futures = {role: pool.submit(materialize, receipt, bundle, progress, clock, label=labels[role]) diff --git a/src/orchard/service.py b/src/orchard/service.py index 9c9d5e0c..dcedcd94 100644 --- a/src/orchard/service.py +++ b/src/orchard/service.py @@ -199,10 +199,9 @@ def install_direct(adapter, device, name, files, firmware_files, boot_files, cha client, host_key, authorized, dropbear_host_key = direct.make_keys(directory) extra = direct.add_on(boot_assets['install_tools'], device['ecid'], authorized, dropbear_host_key) image_file = Path(directory) / 'payload.bin' - image_file.write_bytes(direct.payload(boot_assets['m1n1'], boot_assets.get('logo'), boot_assets['layer'], - adapter.DEVICETREE, extra)) + image_file.write_bytes(direct.boot_image(adapter, boot_assets, extra)) with progress.alongside('Device'): - found = adapter.boot_payload(device, image_file, boot_assets['pongo'], progress) + found = direct.start(adapter, device, image_file, boot_assets, progress) progress.mark('Install mode on USB') progress.pause() progress.step('Connecting over SSH') diff --git a/tests/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c new file mode 100644 index 00000000..3577beac --- /dev/null +++ b/tests/fixtures/cascadia-runtime.c @@ -0,0 +1,101 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* Exercise the producer's actual C entry against anonymous mapped RAM/MMIO. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +/* The assembler patches this record in writable upload RAM on the device. */ +#define const +#include "linux_boot.c" +#undef const + +static unsigned int checks; + +#define CHECK(test) do { \ + checks++; \ + if (!(test)) { fprintf(stderr, "line %d: %s\n", __LINE__, #test); exit(1); } \ +} while (0) + +static void *memory(uintptr_t address, size_t bytes) +{ + void *value = mmap((void *)address, bytes, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0); + CHECK(value == (void *)address); + return value; +} + +static void prepare(void) +{ + struct config value = { + { 'C', 'B', '3', '2' }, 1, sizeof(struct config), LOAD_ADDRESS, + KERNEL_OFFSET, 0x9000, 40, 0xa000, 33, INITRD_ADDRESS, + 0xa000 + 33, WATCHDOG_ADDRESS, L2_ADDRESS, + RAM_START, RAM_END, INITRD_LIMIT, 0, + }; + memcpy((void *)&cascadia_config, &value, sizeof(value)); + unsigned char *ram = (void *)(uintptr_t)LOAD_ADDRESS; + memset(ram, 0, 0x20000); + ram[0x9000] = 0xd0; + ram[0x9001] = 0x0d; + ram[0x9002] = 0xfe; + ram[0x9003] = 0xed; + ram[0x9007] = 40; + for (unsigned int i = 0; i < 33; i++) + ram[0xa000 + i] = (unsigned char)(i ^ 0x5a); + memset((void *)(uintptr_t)INITRD_ADDRESS, 0x77, 0x10000); + memset((void *)(uintptr_t)WATCHDOG_ADDRESS, 0xa5, 16); + cascadia_config.crc32 = checksum(); +} + +static void rejects(void) +{ + CHECK(cascadia_main() == 0); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == 0x77); + for (unsigned int i = 0; i < 16; i++) + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); +} + +int main(void) +{ + CHECK(sizeof(u32) == 4 && sizeof(struct config) == 68); + memory(LOAD_ADDRESS, 0x20000); + memory(INITRD_ADDRESS, 0x10000); + memory(WATCHDOG_ADDRESS & ~4095u, 4096); + prepare(); + CHECK(cascadia_main() == LOAD_ADDRESS + 0x9000); + for (unsigned int i = 0; i < 33; i++) + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[i] == (i ^ 0x5a)); + for (unsigned int i = 0; i < 16; i++) + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); + CHECK(((unsigned char *)(uintptr_t)INITRD_ADDRESS)[33] == 0x77); + + /* Every field is either profile-fixed or has a checked dynamic bound. */ + volatile u32 *fields = (volatile u32 *)&cascadia_config; + for (unsigned int i = 0; i < sizeof(struct config) / sizeof(u32); i++) { + prepare(); + fields[i] = 0xffffffffu; + if (i != 16) + cascadia_config.crc32 = checksum(); + rejects(); + } + prepare(); + cascadia_config.dtb_offset = KERNEL_OFFSET - 4; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + cascadia_config.initrd_offset = 0x9000; + cascadia_config.crc32 = checksum(); + rejects(); + prepare(); + ((unsigned char *)(uintptr_t)(LOAD_ADDRESS + 0x9000))[3] ^= 1; + rejects(); + prepare(); + ((unsigned char *)(uintptr_t)(LOAD_ADDRESS + 0x9000))[7] ^= 1; + rejects(); + printf("%u runtime checks passed\n", checks); + return 0; +} diff --git a/tests/hardware_layout.py b/tests/hardware_layout.py index 754e6ce1..1b4c62b7 100644 --- a/tests/hardware_layout.py +++ b/tests/hardware_layout.py @@ -32,7 +32,7 @@ def patches(name): print(platform, dtb or '-') elif sys.argv[1] == '--platform': platform = resolve(sys.argv[2])[0] - print(json.dumps({'platform': platform, 'arch': 'arm64', 'image': 'Image.gz', + print(json.dumps({'platform': platform, 'arch': 'arm64', 'image': 'Image.gz', 'loader': 'm1n1', 'dtbs': 'arch/arm64/boot/dts/apple', 'upstream': 'kernel/upstream.lock', 'series': 'kernel/series/' + platform, 'config': 'kernel/configs/' + platform + '/config'})) diff --git a/tests/test_armv7_direct.py b/tests/test_armv7_direct.py new file mode 100644 index 00000000..bb5d5a72 --- /dev/null +++ b/tests/test_armv7_direct.py @@ -0,0 +1,261 @@ +"""32-bit boards boot like 64-bit ones, recovery-less: the Cascadia loader takes m1n1's place. + +The same platform-layer kernel, initrd and install add-on; what orchard publishes for 32-bit +roots sits next to the arm64 index, which stays as it was. No device is touched. +""" +import copy +import gzip +import hashlib +import io +import json +from pathlib import Path +import struct +import sys +import tarfile +import tempfile +import tomllib +import unittest +from unittest import mock + +from orchard import inputs, install_direct as direct, releases +from orchard.hardware import ROOT +from orchard.image import cascadia, platform, platforms_index + +sys.path.insert(0, str(ROOT / 'tools')) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from validate_manifest import ManifestError, validate # noqa: E402 +from test_cascadia_loader import kernel, loader, tree # noqa: E402 +from test_install_direct import layer as arm64_layer, manifest as arm64_manifest, receipt # noqa: E402 + +P105 = cascadia.profile(ROOT / 'devices/p105/cascadia.toml') + + +def armv7_layer(path, dtb): + with tarfile.open(path, 'w:gz') as archive: + for name, data in (('usr/lib/modules/6.12/vmlinuz', kernel()), ('usr/lib/modules/6.12/initrd', b'INITRD'), + ('usr/lib/modules/6.12/dtb/s5l8942x-p105.dtb', dtb), + ('usr/lib/modules/6.12/dtb/s5l8945x-j1.dtb', b'OTHER')): + member = tarfile.TarInfo(name) + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + + +def armv7_manifest(): + document = arm64_manifest() + for name in ('pongo', 'm1n1', 'logo'): + del document[name] + document['boot_method'] = 'recovery-autogo' + document['install_tools'] = receipt('install-tools-armv7.tar.gz') + document['cascadia'] = {'p105': receipt('cascadia-p105.bin')} + document['platforms'] = {'apple-armv7': {'boards': ['p105', 'j1'], 'layer': { + **receipt('platform-apple-armv7.tar.gz'), 'compression': 'gzip', 'expanded_bytes': 1, 'members': 1, + 'kernel_release': '6.12'}}} + return document + + +class Payload(unittest.TestCase): + def setUp(self): + work = tempfile.TemporaryDirectory() + self.addCleanup(work.cleanup) + self.work = Path(work.name) + self.dtb = cascadia.write_fdt(tree()) + armv7_layer(self.work / 'layer', self.dtb) + (self.work / 'loader').write_bytes(loader(P105)) + self.adapter = mock.Mock(PROFILE='p105', DEVICETREE='s5l8942x-p105.dtb', NAME='iPad mini 1 (Wi-Fi)') + self.assets = {'cascadia': self.work / 'loader', 'layer': self.work / 'layer', 'install_tools': self.work / 'tools'} + + def test_the_bundle_is_loader_kernel_tree_then_initrd_and_add_on(self): + image = direct.boot_image(self.adapter, self.assets, b'ADD-ON') + config = cascadia.configuration(image) + self.assertEqual(image[0x8000:0x8000 + len(kernel())], kernel()) + # One archive, as on 64-bit: the initrd, padded to four bytes, then the add-on. + self.assertEqual(image[config['initrd_offset']:], b'INITRD\0\0ADD-ON') + self.assertEqual(config['initrd_address'], 0x88000000) + tree_bytes = image[config['dtb_offset']:config['dtb_offset'] + config['dtb_size']] + chosen = cascadia.read_fdt(tree_bytes)[0]['children'][0]['properties'] + self.assertEqual(chosen['bootargs'], b'old\0') # the board's own + self.assertEqual(struct.unpack('>I', chosen['linux,initrd-start'])[0], 0x88000000) + self.assertEqual(struct.unpack('>I', chosen['linux,initrd-end'])[0], 0x88000000 + 14) + + def test_the_loader_must_be_built_for_the_boards_addresses(self): + (self.work / 'loader').write_bytes(loader(dict(P105, initrd_address=0x98000000, initrd_limit=0x9e000000))) + with self.assertRaisesRegex(ValueError, 'differs from the handoff profile'): + direct.boot_image(self.adapter, self.assets, b'ADD-ON') + + def test_64_bit_boards_get_exactly_m1n1s_payload(self): + (self.work / 'm1n1').write_bytes(b'M1N1') + arm64_layer(self.work / 'arm64-layer') + adapter = mock.Mock(PROFILE='j71b', DEVICETREE='t8010-j71b.dtb') + assets = {'m1n1': self.work / 'm1n1', 'layer': self.work / 'arm64-layer', 'pongo': self.work / 'pongo'} + self.assertEqual(direct.boot_image(adapter, assets, b'ADD-ON'), + direct.payload(self.work / 'm1n1', None, self.work / 'arm64-layer', 't8010-j71b.dtb', b'ADD-ON')) + direct.start(adapter, {}, self.work / 'image', assets, None) + adapter.boot_payload.assert_called_once_with({}, self.work / 'image', self.work / 'pongo', None) + + def test_delivering_a_cascadia_bundle_is_not_orchards_yet(self): + with self.assertRaisesRegex(ValueError, 'patched iBEC'): + direct.start(self.adapter, {}, self.work / 'image', self.assets, None) + self.adapter.boot_payload.assert_not_called() + + +class Release(unittest.TestCase): + def test_a_32_bit_release_names_its_boards_loaders_instead_of_m1n1(self): + validate(armv7_manifest()) + both = armv7_manifest() + both['m1n1'] = receipt('m1n1.bin') + with self.assertRaises(ManifestError): + validate(both) + neither = armv7_manifest() + del neither['cascadia'] + with self.assertRaises(ManifestError): + validate(neither) + + def test_a_32_bit_boot_needs_the_layer_the_tools_and_its_boards_loader(self): + _, boot = releases.receipts_for(armv7_manifest(), 'p105', 'boot') + self.assertEqual(boot, {'layer': armv7_manifest()['platforms']['apple-armv7']['layer'], + 'cascadia': receipt('cascadia-p105.bin'), + 'install_tools': receipt('install-tools-armv7.tar.gz')}) + _, install = releases.receipts_for(armv7_manifest(), 'p105', 'install') + self.assertEqual(sorted(install), ['cascadia', 'install_tools', 'layer', 'map', 'root']) + with self.assertRaisesRegex(ValueError, 'no Cascadia loader for j1'): + releases.receipts_for(armv7_manifest(), 'j1', 'boot') + + def test_64_bit_releases_resolve_as_before(self): + _, boot = releases.receipts_for(arm64_manifest(), 'j71b', 'boot') + self.assertEqual(sorted(boot), ['install_tools', 'layer', 'logo', 'm1n1', 'pongo']) + + +class Index(unittest.TestCase): + def setUp(self): + work = tempfile.TemporaryDirectory() + self.addCleanup(work.cleanup) + self.work = Path(work.name) + self.tools = self.work / 'tools' + self.tools.mkdir() + for tool in (*platforms_index.INSTALL_TOOLS, 'busybox'): + (self.tools / tool).write_bytes(b'arm ' + tool.encode()) + (self.work / 'loader.bin').write_bytes(b'LOADER') + + def test_the_32_bit_index_sits_next_to_platforms_toml(self): + def layer(name, directory, *, bundle, busybox, root_architecture): + self.assertEqual((name, root_architecture, busybox), ('apple-armv7', 'arm', self.tools / 'busybox')) + path = directory / f'platform-{name}.tar.gz' + path.write_bytes(b'LAYER') + return {'file': path.name, 'bytes': 5, 'sha256': hashlib.sha256(b'LAYER').hexdigest(), + 'kernel_release': '6.12', 'boards': ['j1', 'p105'], 'architecture': 'arm'} + with mock.patch.object(platform, 'build', layer): + platforms_index.build_armv7(self.work, kernels={'apple-armv7': self.work / 'kernel'}, tools=self.tools, + commit='abc', loaders={'p105': self.work / 'loader.bin'}) + self.assertFalse((self.work / 'platforms.toml').exists()) + index = tomllib.loads((self.work / 'platforms-armv7.toml').read_text()) + self.assertEqual((index['kind'], index['architecture'], index['commit']), ('orchard.platforms', 'arm', 'abc')) + self.assertEqual(sorted(index['loaders']), ['cascadia']) + self.assertEqual(index['loaders']['cascadia']['p105']['file'], 'cascadia-p105.bin') + self.assertEqual(index['install_tools']['file'], 'install-tools-armv7.tar.gz') + for entry in platforms_index.receipts(index): + data = (self.work / entry['file']).read_bytes() + self.assertEqual((entry['bytes'], entry['sha256']), (len(data), hashlib.sha256(data).hexdigest())) + with tarfile.open(self.work / 'install-tools-armv7.tar.gz') as archive: + self.assertEqual(archive.extractfile('dropbear').read(), b'arm dropbear') + + def test_an_inputs_document_of_the_32_bit_root_builds_its_index(self): + document = {'schema': 1, 'orchard': {'commit': 'abc'}, 'architecture': 'arm', + 'platforms': {'apple-armv7': {'kernel': str(self.work / 'kernel'), + 'boards': platform.boards('apple-armv7'), + 'loaders': {'p105': str(self.work / 'loader.bin')}}}, + 'shared': {'tools': {name: str(self.tools / name) for name in (*platforms_index.INSTALL_TOOLS, 'busybox')}}} + with mock.patch.object(platforms_index, 'build_armv7') as build, \ + mock.patch.object(platforms_index, 'build') as arm64: + platforms_index.from_inputs(self.work, document) + arm64.assert_not_called() + self.assertEqual(build.call_args.kwargs['loaders'], {'p105': self.work / 'loader.bin'}) + self.assertEqual(build.call_args.kwargs['commit'], 'abc') + + +class Inputs(unittest.TestCase): + def setUp(self): + work = tempfile.TemporaryDirectory() + self.addCleanup(work.cleanup) + self.work = Path(work.name) + self.tools = self.work / 'tools' + self.tools.mkdir() + for name in inputs.TOOLS: + (self.tools / name).write_bytes(name.encode()) + (self.work / 'loader.bin').write_bytes(b'LOADER') + + def test_the_32_bit_root_gets_its_tools_kernel_and_loaders_and_no_m1n1(self): + with mock.patch('orchard.build.prepare_shared') as shared, \ + mock.patch('orchard.build.prepare_payload_loader') as built_loader, \ + mock.patch('orchard.build.prepare_tools') as built_tools, \ + mock.patch('orchard.image.platform.kernel') as built_kernel: + document = inputs.resolve(['apple-armv7'], {'apple-armv7': self.work}, shared={'tools': self.tools}, + loaders={'p105': self.work / 'loader.bin'}) + for producer in (shared, built_loader, built_tools, built_kernel): + producer.assert_not_called() + self.assertEqual(document['architecture'], 'arm') + row = document['platforms']['apple-armv7'] + self.assertEqual(row['boards'], platform.boards('apple-armv7')) + # j1 has no Cascadia profile yet, so no loader; its DTB is still in the layer. + self.assertEqual(row['loaders'], {'p105': str((self.work / 'loader.bin').resolve())}) + self.assertEqual(set(document['shared']), {'tools'}) + + def test_one_document_is_one_root_architecture(self): + with self.assertRaisesRegex(ValueError, 'one root architecture'): + inputs.resolve(['apple-16k', 'apple-armv7']) + with self.assertRaisesRegex(ValueError, 'no m1n1 or Pongo'): + inputs.resolve(['apple-armv7'], shared={'m1n1': self.work / 'm1n1'}) + with self.assertRaisesRegex(ValueError, 'for ARMv7'): + inputs.resolve(['apple-16k'], loaders={'p105': self.work / 'loader.bin'}) + + +class Initrd(unittest.TestCase): + """A kernel with the USB gadget as modules (orchard's ARMv7 one) gets them in the initrd.""" + def names(self, data): + names, stream = {}, io.BytesIO(gzip.decompress(data)) + while True: + header = stream.read(110) + fields = [int(header[i:i + 8], 16) for i in range(6, 110, 8)] + name = stream.read(fields[11])[:-1].decode() + stream.read(-(110 + fields[11]) % 4) + if name == 'TRAILER!!!': + return names + names[name] = stream.read(fields[6]) + stream.read(-fields[6] % 4) + + def modules(self, lines): + base = 'usr/lib/modules/6.12/' + result = {base + 'modules.dep': ('\n'.join(lines) + '\n').encode()} + for line in lines: + result[base + line.split(':')[0]] = (line.split(':')[0].encode(), 0o644) + result[base + 'modules.dep'] = (result[base + 'modules.dep'], 0o644) + return result + + def test_gadget_modules_load_before_storage_and_cannot_stop_the_boot(self): + g = 'kernel/drivers/usb/gadget/' + lines = [g + 'libcomposite.ko:', g + 'function/u_ether.ko: ' + g + 'libcomposite.ko', + g + 'function/usb_f_ncm.ko: ' + g + 'function/u_ether.ko ' + g + 'libcomposite.ko', + 'kernel/drivers/other.ko:'] + with tempfile.TemporaryDirectory() as temporary: + busybox = Path(temporary) / 'busybox' + busybox.write_bytes(b'busybox') + names = self.names(platform.initrd(self.modules(lines), '6.12', busybox, storage_modules=())) + self.assertEqual(names['etc/orchard/usb-modules'], b'libcomposite\nusb_f_ncm\n') + self.assertEqual(names['etc/orchard/modules'], b'\n') + self.assertEqual(sorted(name.rsplit('/', 1)[1] for name in names if name.endswith('.ko')), + ['libcomposite.ko', 'u_ether.ko', 'usb_f_ncm.ko']) + init = (ROOT / 'boot/initramfs/root/init').read_text() + self.assertLess(init.index('/etc/orchard/usb-modules'), init.index('$(cat /etc/orchard/modules)')) + self.assertIn('modprobe "$module" || echo', init) + + def test_kernels_with_the_gadget_built_in_get_no_list(self): + lines = ['kernel/drivers/nvme/host/nvme.ko:'] + with tempfile.TemporaryDirectory() as temporary: + busybox = Path(temporary) / 'busybox' + busybox.write_bytes(b'busybox') + names = self.names(platform.initrd(self.modules(lines), '6.12', busybox)) + self.assertNotIn('etc/orchard/usb-modules', names) + self.assertEqual(names['etc/orchard/modules'], b'nvme\n') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_armv7_producers.py b/tests/test_armv7_producers.py new file mode 100644 index 00000000..6def88bf --- /dev/null +++ b/tests/test_armv7_producers.py @@ -0,0 +1,172 @@ +"""ARMv7 producer boundaries use synthetic ELF and DTB inputs, with no device access.""" +import gzip +import hashlib +import io +import json +from pathlib import Path +import struct +import tarfile +import tempfile +import unittest +from unittest import mock + +from orchard import build +from orchard.hardware import ROOT +from orchard.image import elf, platform + + +def executable(architecture='arm', dynamic=False): + width = 1 if architecture == 'arm' else 2 + data = bytearray(384) + data[:7] = b'\x7fELF' + bytes((width, 1, 1)) + struct.pack_into('I', cells), + 'compatible': b'example,test\0'}, + 'children': [{'name': 'chosen', 'properties': {'bootargs': b'old\0', 'stdout-path': b'serial0\0', + 'linux,initrd-start': struct.pack('>I', 0x88000000), + 'linux,initrd-end': struct.pack('>I', 0x88000000)}, + 'children': []}, + {'name': 'memory@80000000', 'properties': {'device_type': b'memory\0', + 'reg': b'\x80\0\0\0\x20\0\0\0'}, + 'children': []}]} + + +class CascadiaLoaderTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == 'linux' and shutil.which('cc'), 'The C runtime fixture needs Linux mmap and a C compiler') + def test_actual_runtime_c_copy_and_rejection_paths(self): + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + (folder / 'cascadia-generated.h').write_text(''.join( + f'#define {name.upper()} {hex(value)}\n' for name, value in PROFILE.items() if name != 'schema')) + executable = folder / 'runtime-check' + command = ['cc', '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror', '-Wno-int-to-pointer-cast', + '-I', str(folder), '-I', str(ROOT / 'boot/cascadia'), + str(ROOT / 'tests/fixtures/cascadia-runtime.c'), '-o', str(executable)] + compiled = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('runtime checks passed', result.stdout) + + def test_elf_entry_and_raw_code_must_match(self): + binary = loader() + elf = executable(binary) + cascadia.verify_loader(binary, PROFILE, elf) + shifted = bytearray(elf) + struct.pack_into('I', bad, offset, value) + with self.assertRaises(ValueError): + cascadia.read_fdt(bad) + with self.assertRaisesRegex(ValueError, 'overlap'): + bad = bytearray(original) + struct.pack_into('>I', bad, 12, struct.unpack_from('>I', bad, 8)[0]) + cascadia.read_fdt(bad) + + def test_wrong_kernel_and_out_of_bounds_initramfs_refused(self): + with self.assertRaisesRegex(ValueError, 'ARM zImage'): + self.assemble(kernel=b'arm64 Image') + with self.assertRaisesRegex(ValueError, 'length'): + self.assemble(kernel=kernel() + b'appended old dtb') + smaller = dict(PROFILE, initrd_limit=PROFILE['initrd_address'] + 8) + with self.assertRaisesRegex(ValueError, 'destination'): + self.assemble(loader=loader(smaller), described=smaller, elf=None) + + def test_a_tree_without_initrd_placeholders_is_refused(self): + value = tree() + del value['children'][0]['properties']['linux,initrd-end'] + with self.assertRaisesRegex(ValueError, 'placeholders'): + self.assemble(dtb=cascadia.write_fdt(value)) + value = tree(2) + value['children'][0]['properties']['linux,initrd-start'] = bytes(8) + with self.assertRaisesRegex(ValueError, 'one cell'): + self.assemble(dtb=cascadia.write_fdt(value)) + + def test_decompressor_workspace_cannot_reach_high_initramfs(self): + large = bytearray(kernel()) + struct.pack_into('