From 893edc6f0e84955db4ffbf7313be1fdbb9733961 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:57:28 -0600 Subject: [PATCH 1/9] build: produce static ARMv7 recovery tools Select ARM/v7 or ARM64 tool recipes explicitly and keep their outputs in separate caches. Pin Debian's ARMHF BusyBox package and verify each finished tool's ELF architecture and static linkage. Native builds require a host whose architecture matches the selected target. Assisted-by: LLM (cherry picked from commit 8d859a307786845137366569bfd192a87a18244a, OrchardLinux/orchard#82) --- boot/initramfs/recovery/build-tools | 29 ++++-- .../recovery/build-tools-in-container | 13 ++- boot/initramfs/recovery/check-tools.py | 77 +++++++++++++++ boot/initramfs/recovery/install-toolchain | 2 +- boot/initramfs/recovery/upstream.lock | 4 + src/orchard/buildenv.py | 31 ++++--- tests/test_recovery_tools_arch.py | 93 +++++++++++++++++++ 7 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 boot/initramfs/recovery/check-tools.py create mode 100644 tests/test_recovery_tools_arch.py diff --git a/boot/initramfs/recovery/build-tools b/boot/initramfs/recovery/build-tools index a5ea574e..dd8a7fa5 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,11 @@ 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 +image=localhost/orchard-hardware-recovery-tools:bookworm +[[ "$arch" == arm64 ]] || image=localhost/orchard-hardware-recovery-tools-arm:bookworm +"$python" "$here/../../../src/orchard/buildenv.py" build --arch "$arch" --file "$here/Containerfile" \ + --image "$image" --context "$here" +"$python" "$here/../../../src/orchard/buildenv.py" run --arch "$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..8186df88 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" @@ -67,7 +70,7 @@ tar -xJf "$work/iw.tar.xz" -C "$build" 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 $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" @@ -78,11 +81,13 @@ tar -xzf "$work/e2fsprogs.tar.gz" -C "$build" 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..6c33aaad 100644 --- a/boot/initramfs/recovery/install-toolchain +++ b/boot/initramfs/recovery/install-toolchain @@ -1,6 +1,6 @@ #!/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) +packages=(bison build-essential bzip2 ca-certificates dpkg file flex linux-libc-dev musl-tools pkg-config python3 xz-utils) if [[ ${1:-} == --packages ]]; then printf '%s\n' "${packages[@]}"; exit 0; fi apt-get update apt-get install -y --no-install-recommends "${packages[@]}" 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/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/tests/test_recovery_tools_arch.py b/tests/test_recovery_tools_arch.py new file mode 100644 index 00000000..8277841f --- /dev/null +++ b/tests/test_recovery_tools_arch.py @@ -0,0 +1,93 @@ +"""Recovery tools must match their target ISA before entering an image.""" +import importlib.util +import json +import os +from pathlib import Path +import struct +import unittest +from unittest import mock + +from orchard import buildenv + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('check_recovery_tools', + ROOT / 'boot/initramfs/recovery/check-tools.py') +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +def elf(arch, *, kind=1, dynamic_tag=None, flags=0x05000400): + arm = arch == 'arm' + header_size, ph_size = (52, 32) if arm else (64, 56) + ident = b'\x7fELF' + bytes((1 if arm else 2, 1, 1)) + bytes(9) + dynamic = struct.pack(' Date: Fri, 6 Nov 2026 11:57:46 -0600 Subject: [PATCH 2/9] boot: build Cascadia payloads from source Use board-owned handoff data to compile the ARM32 staging loader. Preserve Cascadia's cache/MMU handoff and require executable code at the upload address. A checked configuration places the supplied external initramfs in reserved RAM before entering Linux through its edited DTB. Bound the zImage decompressor workspace and preserve the kernel bytes. Add synthetic layout tests and a Linux fixture that exercises the actual loader C copy and rejection paths. Source provenance is recorded in NOTICE. The external-initramfs handoff still requires physical qualification. Assisted-by: LLM (cherry picked from commit 12928a355bcff56e71b46340ff4b2b576767b357, OrchardLinux/orchard#82; left out here: the chips/s5l8942x.toml, devices/p105/device.toml and README notes) --- boot/cascadia/NOTICE | 14 ++ boot/cascadia/README.md | 37 +++ boot/cascadia/build | 50 ++++ boot/cascadia/compile | 14 ++ boot/cascadia/linux-boot.ld.in | 22 ++ boot/cascadia/linux_boot.c | 87 +++++++ boot/cascadia/linux_boot_start.S | 135 ++++++++++ devices/p105/cascadia.toml | 12 + src/orchard/hardware.py | 10 + src/orchard/image/cascadia.py | 393 ++++++++++++++++++++++++++++++ tests/fixtures/cascadia-runtime.c | 101 ++++++++ tests/test_cascadia_loader.py | 239 ++++++++++++++++++ 12 files changed, 1114 insertions(+) create mode 100644 boot/cascadia/NOTICE create mode 100644 boot/cascadia/README.md create mode 100755 boot/cascadia/build create mode 100755 boot/cascadia/compile create mode 100644 boot/cascadia/linux-boot.ld.in create mode 100644 boot/cascadia/linux_boot.c create mode 100644 boot/cascadia/linux_boot_start.S create mode 100644 devices/p105/cascadia.toml create mode 100644 src/orchard/image/cascadia.py create mode 100644 tests/fixtures/cascadia-runtime.c create mode 100644 tests/test_cascadia_loader.py diff --git a/boot/cascadia/NOTICE b/boot/cascadia/NOTICE new file mode 100644 index 00000000..a20b5572 --- /dev/null +++ b/boot/cascadia/NOTICE @@ -0,0 +1,14 @@ +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 and DTB editor add +bounded external-initramfs placement; that extension requires a fresh +hardware boot before it is described as working. diff --git a/boot/cascadia/README.md b/boot/cascadia/README.md new file mode 100644 index 00000000..2a5eccd6 --- /dev/null +++ b/boot/cascadia/README.md @@ -0,0 +1,37 @@ +# ARM32 iBEC staging payload + +`build --profile devices/p105/cascadia.toml --output DIR` builds a loader +with Orchard's kernel toolchain through `buildenv`. 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. + +`python -m orchard.image.cascadia --profile devices/p105/cascadia.toml +--loader DIR/loader.bin --loader-elf DIR/loader.elf --kernel zImage +--dtb s5l8942x-p105.dtb --initramfs recovery.cpio.gz --bootargs 'rdinit=/init' +--output linux.bin` assembles one device's payload. Kernel bytes remain +unchanged. The DTB editor preserves properties while updating `/chosen` +boot arguments and its initrd addresses, then reserves that destination. + +The layout is a checked loader followed by padding to 32 KiB, the zImage, +the edited DTB and the supplied initramfs, with each data address aligned to +eight bytes. The loader validates its configuration checksum and bounds +before copying the initramfs to the profile's reserved high-memory range. +It then performs the Cascadia cache/MMU handoff with the DTB in `r2`. +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 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. It records +`architecture = "arm"` and `initramfs_encoding = "cascadia-dtb"`, allowing a +distribution to reject an image assembled with another architecture or +payload format. This external-initramfs extension awaits hardware testing; +the earlier P105 boot embedded its initramfs in the kernel. + +See `NOTICE` for source provenance. This producer contacts no device. 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..16526a73 --- /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 (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; + if (WATCHDOG_ADDRESS) { + volatile u32 *wdt = (volatile u32 *)WATCHDOG_ADDRESS; + for (i = 0; i < 4; i++) + wdt[i] = 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/devices/p105/cascadia.toml b/devices/p105/cascadia.toml new file mode 100644 index 00000000..95f23a4a --- /dev/null +++ b/devices/p105/cascadia.toml @@ -0,0 +1,12 @@ +# P105 handoff addresses from the Cascadia-derived staging loader used for the +# owner's successful iBEC boot. The external-initramfs placement is new and +# requires a fresh hardware test with this producer. +schema = 1 +load_address = 0x80000000 +kernel_offset = 0x8000 +watchdog_address = 0x0f103020 +l2_address = 0x3e000000 +ram_start = 0x80000000 +ram_end = 0xa0000000 +initrd_address = 0x98000000 +initrd_limit = 0x9e000000 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..6b02caff --- /dev/null +++ b/src/orchard/image/cascadia.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Assemble an ARM32 iBEC payload with an external initramfs described by its DTB. + +The loader occupies the first 32 KiB. Its checked configuration tells the +loader where to copy the initramfs before entering the unchanged zImage. +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 chosen(data, bootargs, start, size): + if not isinstance(bootargs, str) or any(char in bootargs for char in ('\0', '\n', '\r')): + raise ValueError('Kernel arguments must be one line') + encoded = bootargs.encode('utf-8') + if len(encoded) > 2047: + raise ValueError('Kernel arguments exceed the ARM command-line bound') + root, reservations, cpu = read_fdt(data) + cell_bytes = root['properties'].get('#address-cells', struct.pack('>I', 2)) + if len(cell_bytes) != 4 or (cells := struct.unpack('>I', cell_bytes)[0]) not in (1, 2): + raise ValueError('Expected one or two root address cells') + selected = next((item for item in root['children'] if item['name'] == 'chosen'), None) + if selected is None: + selected = {'name': 'chosen', 'properties': {}, 'children': []} + root['children'].append(selected) + properties = selected['properties'] + width = cells * 4 + previous_start = properties.get('linux,initrd-start', b'') + previous_end = properties.get('linux,initrd-end', b'') + if len(previous_start) == len(previous_end) == width: + old_start, old_end = int.from_bytes(previous_start, 'big'), int.from_bytes(previous_end, 'big') + reservations = [row for row in reservations if row != (old_start, old_end - old_start)] + if any(max(start, address) < min(start + size, address + length) for address, length in reservations): + raise ValueError('Initramfs destination overlaps reserved memory') + properties['bootargs'] = encoded + b'\0' + properties['linux,initrd-start'] = start.to_bytes(width, 'big') + properties['linux,initrd-end'] = (start + size).to_bytes(width, 'big') + reservations.append((start, size)) + return write_fdt(root, reservations, cpu) + + +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, bootargs, *, elf=None): + 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 = chosen(dtb, bootargs, 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]) + given = parser.add_mutually_exclusive_group(required=True) + given.add_argument('--bootargs') + given.add_argument('--manifest', type=Path) + for name in ('loader', 'kernel', 'initramfs', 'profile', 'output'): + parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--loader-elf', type=Path) + parser.add_argument('--dtb', action='append', type=Path, required=True) + parser.add_argument('--logo', type=Path) + args = parser.parse_args() + if len(args.dtb) != 1: + parser.error('A Cascadia payload takes exactly one device tree') + if args.logo: + parser.error('Cascadia payloads do not implement the m1n1 logo format') + 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: + bootargs = args.bootargs + if args.manifest: + bootargs = json.loads(args.manifest.read_text())['bootargs'] + prefix = 'chosen.bootargs=' + if not bootargs.startswith(prefix) or not bootargs.endswith('\n'): + raise ValueError('Invalid boot-argument manifest') + bootargs = bootargs[len(prefix):-1] + data, document = assemble(args.loader.read_bytes(), args.kernel.read_bytes(), args.dtb[0].read_bytes(), + args.initramfs.read_bytes(), profile(args.profile), bootargs, + 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/tests/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c new file mode 100644 index 00000000..b3f276e9 --- /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] == 0xa5); +} + +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/test_cascadia_loader.py b/tests/test_cascadia_loader.py new file mode 100644 index 00000000..7f8bae26 --- /dev/null +++ b/tests/test_cascadia_loader.py @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: GPL-2.0-only +import json +from pathlib import Path +import struct +import shutil +import subprocess +import sys +import tempfile +import unittest +import zlib + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +from orchard.image import cascadia + + +PROFILE = dict(schema=1, load_address=0x80000000, kernel_offset=0x8000, + watchdog_address=0x0f103020, l2_address=0x3e000000, + ram_start=0x80000000, ram_end=0xa0000000, + initrd_address=0x98000000, initrd_limit=0x9e000000) + + +def loader(described=PROFILE): + record = dict(magic=b'CB32', version=1, header_size=cascadia.CONFIG.size, + dtb_offset=0, dtb_size=0, initrd_offset=0, initrd_size=0, + image_size=0, crc32=0) + record.update({key: value for key, value in described.items() if key != 'schema'}) + raw = bytearray(cascadia.CONFIG_OFFSET + cascadia.CONFIG.size) + raw[:4] = bytes.fromhex('c0000cf1') + raw[cascadia.CONFIG_OFFSET:] = cascadia.CONFIG.pack(*(record[key] for key in cascadia.CONFIG_FIELDS)) + return bytes(raw) + + +def executable(raw, described=PROFILE): + data = bytearray(0x300 + 3 * 40) + data[:7] = b'\x7fELF\x01\x01\x01' + struct.pack_into('I', cells), + 'compatible': b'example,test\0'}, + 'children': [{'name': 'chosen', 'properties': {'bootargs': b'old\0', 'stdout-path': b'serial0\0'}, + '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', 0x98000000), + 'linux,initrd-end': struct.pack('>I', 0x98000010)}) + old = cascadia.write_fdt(value, [(0x98000000, 16), (0x9f000000, 4096)]) + new = cascadia.chosen(old, 'new', 0x98000000, 32) + self.assertEqual(cascadia.read_fdt(new)[1], [(0x9f000000, 4096), (0x98000000, 32)]) + + def test_reserved_destination_refused(self): + old = cascadia.write_fdt(tree(), [(PROFILE['initrd_address'] + 4, 4096)]) + with self.assertRaisesRegex(ValueError, 'reserved memory'): + self.assemble(dtb=old) + + def test_fdt_corruption_is_rejected(self): + original = cascadia.write_fdt(tree()) + for offset, value in ((4, len(original) + 1), (8, 0xffffffff), (12, 0), (16, 41), (20, 16)): + with self.subTest(offset=offset): + bad = bytearray(original) + 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) + with self.assertRaisesRegex(ValueError, 'one line'): + self.assemble(bootargs='a\nb') + + def test_decompressor_workspace_cannot_reach_high_initramfs(self): + large = bytearray(kernel()) + struct.pack_into(' Date: Fri, 6 Nov 2026 17:03:06 -0600 Subject: [PATCH 3/9] boot: stop the Cascadia watchdog before validation Clear the profile watchdog before reading the checked handoff configuration. Rejected payloads now remain available for inspection instead of resetting while the loader waits. Cover the rejected-config path in the Linux runtime fixture. Assisted-by: LLM (cherry picked from commit 78ed09b1c4d17949ad073333b27832ba67f6db5e) --- boot/cascadia/README.md | 6 ++++-- boot/cascadia/linux_boot.c | 10 +++++----- tests/fixtures/cascadia-runtime.c | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/boot/cascadia/README.md b/boot/cascadia/README.md index 2a5eccd6..3ebf964b 100644 --- a/boot/cascadia/README.md +++ b/boot/cascadia/README.md @@ -16,8 +16,10 @@ boot arguments and its initrd addresses, then reserves that destination. The layout is a checked loader followed by padding to 32 KiB, the zImage, the edited DTB and the supplied initramfs, with each data address aligned to -eight bytes. The loader validates its configuration checksum and bounds -before copying the initramfs to the profile's reserved high-memory range. +eight bytes. The loader disables the profile's watchdog before it validates +the configuration, so a rejected image remains stopped for inspection. It +checks the configuration checksum and bounds before copying the initramfs to +the profile's reserved high-memory range. It then performs the Cascadia cache/MMU handoff with the DTB in `r2`. The fixed loader stack uses the gap below offset `0x7000`; code must finish below `0x6000`, and the configuration starts at `0x7f00`. diff --git a/boot/cascadia/linux_boot.c b/boot/cascadia/linux_boot.c index 16526a73..45061372 100644 --- a/boot/cascadia/linux_boot.c +++ b/boot/cascadia/linux_boot.c @@ -51,6 +51,11 @@ u32 cascadia_main(void) 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) || @@ -74,11 +79,6 @@ u32 cascadia_main(void) dtb = (volatile const u8 *)(LOAD_ADDRESS + c->dtb_offset); if (big_endian(dtb) != 0xd00dfeedu || big_endian(dtb + 4) != c->dtb_size) return 0; - if (WATCHDOG_ADDRESS) { - volatile u32 *wdt = (volatile u32 *)WATCHDOG_ADDRESS; - for (i = 0; i < 4; i++) - wdt[i] = 0; - } from = (volatile const u8 *)(LOAD_ADDRESS + c->initrd_offset); to = (volatile u8 *)INITRD_ADDRESS; for (i = 0; i < c->initrd_size; i++) diff --git a/tests/fixtures/cascadia-runtime.c b/tests/fixtures/cascadia-runtime.c index b3f276e9..3577beac 100644 --- a/tests/fixtures/cascadia-runtime.c +++ b/tests/fixtures/cascadia-runtime.c @@ -56,7 +56,7 @@ static void rejects(void) 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] == 0xa5); + CHECK(((unsigned char *)(uintptr_t)WATCHDOG_ADDRESS)[i] == 0); } int main(void) From 7fda15c683a7c32e762fa9099ed5ee470ea98289 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:29:36 +0800 Subject: [PATCH 4/9] build: resolve ARMv7 platform layers, tools and Cascadia RAM payloads Platform layers use the declared kernel image and the platform's own early storage modules (an empty list for built-in storage), and check the root architecture they are asked for. Static recovery tools are built per architecture into separate cache entries; the Cascadia loader is built from the board's profile; RAM payloads of a Cascadia platform are assembled for one selected board through the Cascadia producer, without Pongo. The ELF reader handles ELF32 and ELF64 alike. Assisted-by: LLM Adapted from commit 02dcc194b85809c36dbef2ba5578275186fba82f (OrchardLinux/orchard#82). Left out here: board selection for distribution builds (orchard.inputs --device, selects-device), per-board platform layers, the recovery's runtime staging and its producer-contract text. arm64 platform layers keep their platform.json and receipt unchanged; the ARMv7 layer holds every board's DTB. --- platforms/apple-armv7.toml | 5 + src/orchard/build.py | 59 ++++++++-- src/orchard/image/elf.py | 105 ++++++++++++++---- src/orchard/image/platform.py | 65 ++++++++--- src/orchard/ram.py | 76 ++++++++++--- tests/hardware_layout.py | 2 +- tests/test_armv7_producers.py | 172 ++++++++++++++++++++++++++++++ tests/test_recovery_tools_arch.py | 56 +++++++++- 8 files changed, 478 insertions(+), 62 deletions(-) create mode 100644 tests/test_armv7_producers.py 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/src/orchard/build.py b/src/orchard/build.py index b57711c5..a11eeecc 100644 --- a/src/orchard/build.py +++ b/src/orchard/build.py @@ -491,7 +491,7 @@ def has_loader_patches(hardware): 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'] + '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/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..04c00c66 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 @@ -49,7 +51,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 +59,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 +81,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,9 +93,9 @@ 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)}') + 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)}') wanted, pending = [], [by_name[name.replace('_', '-')] for name in loaded] while pending: name = pending.pop() @@ -121,14 +123,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 +165,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 +188,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 +226,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/ram.py b/src/orchard/ram.py index d0ba1eac..c24c47dd 100644 --- a/src/orchard/ram.py +++ b/src/orchard/ram.py @@ -20,8 +20,13 @@ IMAGE = Path(__file__).resolve().parent / 'image' # standalone scripts, run by path -def check_busybox(path): +def check_busybox(path, architecture='arm64'): data = path.read_bytes() + if architecture == 'arm': + from .image.elf import architecture as elf_architecture, _parts + if elf_architecture(data) != architecture or any(kind in (2, 3) for kind, *_ in _parts(data)[2]): + raise ValueError('BusyBox must be static ARM EABI5 hard-float ELF32') + return if len(data) < 64 or data[:6] != b'\x7fELF\x02\x01' or data[18:20] != b'\xb7\x00': raise ValueError('BusyBox must be little-endian AArch64 ELF64') offset = struct.unpack_from(' 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): - command(sys.executable, IMAGE / 'assemble.py', '--bootargs', bootargs, '--loader', loader, +def assemble(bootargs, loader, dtbs, kernel, initramfs, output, *, profile=None): + command(sys.executable, IMAGE / ('cascadia.py' if profile is not None else 'assemble.py'), + *(['--profile', profile] if profile is not None else []), + '--bootargs', bootargs, '--loader', loader, *(argument for dtb in dtbs for argument in ('--dtb', dtb)), '--kernel', kernel, '--initramfs', initramfs, '--output', output) @@ -218,7 +254,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 +343,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 +361,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/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_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(' Date: Sat, 7 Nov 2026 20:31:10 +0800 Subject: [PATCH 5/9] boot: Cascadia payloads in the form Project Cascadia booted P105 with The initramfs goes to 0x88000000, where Cascadia's own P105 boot put its extra archive and where the board DTB's /chosen initrd placeholders point, inside what iBEC maps; the loader copies it there before it turns the MMU off, as before. The DTB's two initrd cells are set in place, as Cascadia's assemble.py does, instead of rewriting the tree: its boot arguments stay the board's, and no memory reservation is added. A tree without the placeholders is refused. RAM payloads of a Cascadia board pass no m1n1 boot arguments. Assisted-by: LLM --- boot/cascadia/NOTICE | 8 ++- boot/cascadia/README.md | 67 ++++++++++++--------- devices/p105/cascadia.toml | 14 +++-- src/orchard/image/cascadia.py | 109 ++++++++++++++++++---------------- src/orchard/ram.py | 12 +++- tests/test_cascadia_loader.py | 53 +++++++---------- 6 files changed, 140 insertions(+), 123 deletions(-) diff --git a/boot/cascadia/NOTICE b/boot/cascadia/NOTICE index a20b5572..2912ab3f 100644 --- a/boot/cascadia/NOTICE +++ b/boot/cascadia/NOTICE @@ -9,6 +9,8 @@ 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 and DTB editor add -bounded external-initramfs placement; that extension requires a fresh -hardware boot before it is described as working. +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 index 3ebf964b..bf659508 100644 --- a/boot/cascadia/README.md +++ b/boot/cascadia/README.md @@ -1,39 +1,48 @@ -# ARM32 iBEC staging payload +# ARM32 iBEC staging payload (Cascadia loader) -`build --profile devices/p105/cascadia.toml --output DIR` builds a loader -with Orchard's kernel toolchain through `buildenv`. 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 +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. -`python -m orchard.image.cascadia --profile devices/p105/cascadia.toml ---loader DIR/loader.bin --loader-elf DIR/loader.elf --kernel zImage ---dtb s5l8942x-p105.dtb --initramfs recovery.cpio.gz --bootargs 'rdinit=/init' ---output linux.bin` assembles one device's payload. Kernel bytes remain -unchanged. The DTB editor preserves properties while updating `/chosen` -boot arguments and its initrd addresses, then reserves that destination. +`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 edited DTB and the supplied initramfs, with each data address aligned to -eight bytes. The loader disables the profile's watchdog before it validates -the configuration, so a rejected image remains stopped for inspection. It -checks the configuration checksum and bounds before copying the initramfs to -the profile's reserved high-memory range. -It then performs the Cascadia cache/MMU handoff with the DTB in `r2`. -The fixed loader stack uses the gap below offset `0x7000`; code must finish -below `0x6000`, and the configuration starts at `0x7f00`. +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 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. +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. -The receipt describes every byte range and its hash. It records -`architecture = "arm"` and `initramfs_encoding = "cascadia-dtb"`, allowing a -distribution to reject an image assembled with another architecture or -payload format. This external-initramfs extension awaits hardware testing; -the earlier P105 boot embedded its initramfs in the kernel. +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. +See `NOTICE` for source provenance. This producer contacts no device; the +payload has not been booted on hardware yet. diff --git a/devices/p105/cascadia.toml b/devices/p105/cascadia.toml index 95f23a4a..842931b5 100644 --- a/devices/p105/cascadia.toml +++ b/devices/p105/cascadia.toml @@ -1,6 +1,10 @@ -# P105 handoff addresses from the Cascadia-derived staging loader used for the -# owner's successful iBEC boot. The external-initramfs placement is new and -# requires a fresh hardware test with this producer. +# 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 @@ -8,5 +12,5 @@ watchdog_address = 0x0f103020 l2_address = 0x3e000000 ram_start = 0x80000000 ram_end = 0xa0000000 -initrd_address = 0x98000000 -initrd_limit = 0x9e000000 +initrd_address = 0x88000000 +initrd_limit = 0x90000000 diff --git a/src/orchard/image/cascadia.py b/src/orchard/image/cascadia.py index 6b02caff..afee424a 100644 --- a/src/orchard/image/cascadia.py +++ b/src/orchard/image/cascadia.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-only -"""Assemble an ARM32 iBEC payload with an external initramfs described by its DTB. +"""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 the -loader where to copy the initramfs before entering the unchanged zImage. +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 @@ -237,34 +240,48 @@ def node(item): 40, 17, 16, cpu, len(names), len(tree)) + reserve + tree + names) -def chosen(data, bootargs, start, size): - if not isinstance(bootargs, str) or any(char in bootargs for char in ('\0', '\n', '\r')): - raise ValueError('Kernel arguments must be one line') - encoded = bootargs.encode('utf-8') - if len(encoded) > 2047: - raise ValueError('Kernel arguments exceed the ARM command-line bound') - root, reservations, cpu = read_fdt(data) - cell_bytes = root['properties'].get('#address-cells', struct.pack('>I', 2)) - if len(cell_bytes) != 4 or (cells := struct.unpack('>I', cell_bytes)[0]) not in (1, 2): - raise ValueError('Expected one or two root address cells') - selected = next((item for item in root['children'] if item['name'] == 'chosen'), None) - if selected is None: - selected = {'name': 'chosen', 'properties': {}, 'children': []} - root['children'].append(selected) - properties = selected['properties'] - width = cells * 4 - previous_start = properties.get('linux,initrd-start', b'') - previous_end = properties.get('linux,initrd-end', b'') - if len(previous_start) == len(previous_end) == width: - old_start, old_end = int.from_bytes(previous_start, 'big'), int.from_bytes(previous_end, 'big') - reservations = [row for row in reservations if row != (old_start, old_end - old_start)] - if any(max(start, address) < min(start + size, address + length) for address, length in reservations): - raise ValueError('Initramfs destination overlaps reserved memory') - properties['bootargs'] = encoded + b'\0' - properties['linux,initrd-start'] = start.to_bytes(width, 'big') - properties['linux,initrd-end'] = (start + size).to_bytes(width, 'big') - reservations.append((start, size)) - return write_fdt(root, reservations, cpu) +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): @@ -301,7 +318,11 @@ def decompression_bound(kernel, dtb_size, described): return bound -def assemble(loader, kernel, dtb, initramfs, described, bootargs, *, elf=None): +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 = chosen(dtb, bootargs, described['initrd_address'], len(initramfs)) + 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) @@ -350,32 +371,16 @@ def append(name, payload): def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) - given = parser.add_mutually_exclusive_group(required=True) - given.add_argument('--bootargs') - given.add_argument('--manifest', type=Path) - for name in ('loader', 'kernel', 'initramfs', 'profile', 'output'): + for name in ('loader', 'kernel', 'dtb', 'initramfs', 'profile', 'output'): parser.add_argument('--' + name, type=Path, required=True) parser.add_argument('--loader-elf', type=Path) - parser.add_argument('--dtb', action='append', type=Path, required=True) - parser.add_argument('--logo', type=Path) args = parser.parse_args() - if len(args.dtb) != 1: - parser.error('A Cascadia payload takes exactly one device tree') - if args.logo: - parser.error('Cascadia payloads do not implement the m1n1 logo format') 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: - bootargs = args.bootargs - if args.manifest: - bootargs = json.loads(args.manifest.read_text())['bootargs'] - prefix = 'chosen.bootargs=' - if not bootargs.startswith(prefix) or not bootargs.endswith('\n'): - raise ValueError('Invalid boot-argument manifest') - bootargs = bootargs[len(prefix):-1] - data, document = assemble(args.loader.read_bytes(), args.kernel.read_bytes(), args.dtb[0].read_bytes(), - args.initramfs.read_bytes(), profile(args.profile), bootargs, + 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)) diff --git a/src/orchard/ram.py b/src/orchard/ram.py index c24c47dd..ae7aa23b 100644 --- a/src/orchard/ram.py +++ b/src/orchard/ram.py @@ -141,9 +141,15 @@ def assemble_ram(hardware, output, *, kernel, busybox, loader, pongo, init, def assemble(bootargs, loader, dtbs, kernel, initramfs, output, *, profile=None): - command(sys.executable, IMAGE / ('cascadia.py' if profile is not None else 'assemble.py'), - *(['--profile', profile] if profile is not None else []), - '--bootargs', bootargs, '--loader', loader, + 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) diff --git a/tests/test_cascadia_loader.py b/tests/test_cascadia_loader.py index 7f8bae26..e9ac885f 100644 --- a/tests/test_cascadia_loader.py +++ b/tests/test_cascadia_loader.py @@ -17,7 +17,7 @@ PROFILE = dict(schema=1, load_address=0x80000000, kernel_offset=0x8000, watchdog_address=0x0f103020, l2_address=0x3e000000, ram_start=0x80000000, ram_end=0xa0000000, - initrd_address=0x98000000, initrd_limit=0x9e000000) + initrd_address=0x88000000, initrd_limit=0x90000000) def loader(described=PROFILE): @@ -57,7 +57,9 @@ def kernel(): def tree(cells=1): return {'name': '', 'properties': {'#address-cells': struct.pack('>I', cells), 'compatible': b'example,test\0'}, - 'children': [{'name': 'chosen', 'properties': {'bootargs': b'old\0', 'stdout-path': b'serial0\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'}, @@ -113,7 +115,7 @@ def test_rejects_wrong_profile_and_previously_assembled_loader(self): def assemble(self, **changes): arguments = dict(loader=loader(), kernel=kernel(), dtb=cascadia.write_fdt(tree()), - initramfs=b'compressed-cpio', described=PROFILE, bootargs='console=tty0 rdinit=/init', + initramfs=b'compressed-cpio', described=PROFILE, elf=executable(loader())) arguments.update(changes) return cascadia.assemble(**arguments) @@ -129,10 +131,14 @@ def test_complete_payload_preserves_kernel_and_describes_every_byte(self): root, reservations, _ = cascadia.read_fdt(dtb) chosen = root['children'][0]['properties'] self.assertEqual(chosen['stdout-path'], b'serial0\0') - self.assertEqual(chosen['bootargs'], b'console=tty0 rdinit=/init\0') + self.assertEqual(chosen['bootargs'], b'old\0') self.assertEqual(int.from_bytes(chosen['linux,initrd-start'], 'big'), PROFILE['initrd_address']) self.assertEqual(int.from_bytes(chosen['linux,initrd-end'], 'big'), PROFILE['initrd_address'] + len(b'compressed-cpio')) - self.assertEqual(reservations, [(PROFILE['initrd_address'], len(b'compressed-cpio'))]) + self.assertEqual(reservations, []) + # In place, as Cascadia's assemble.py: only the two initrd cells differ from the input tree. + source = cascadia.write_fdt(tree()) + self.assertEqual(len(dtb), len(source)) + self.assertLessEqual(sum(a != b for a, b in zip(dtb, source)), 8) position = 0 for component in receipt['components']: self.assertEqual(component['offset'], position) @@ -144,29 +150,6 @@ def test_complete_payload_preserves_kernel_and_describes_every_byte(self): self.assertEqual(receipt['architecture'], 'arm') self.assertEqual(receipt['initramfs_encoding'], 'cascadia-dtb') - def test_chosen_creation_and_two_address_cells(self): - value = tree(2) - value['children'].pop(0) - dtb = cascadia.chosen(cascadia.write_fdt(value), 'test=1', PROFILE['initrd_address'], 12) - root, _, _ = cascadia.read_fdt(dtb) - self.assertEqual(root['children'][0], value['children'][0]) - chosen = root['children'][-1]['properties'] - self.assertEqual(len(chosen['linux,initrd-start']), 8) - self.assertEqual(int.from_bytes(chosen['linux,initrd-start'], 'big'), PROFILE['initrd_address']) - - def test_previous_initrd_reservation_replaced_and_other_reservations_retained(self): - value = tree() - value['children'][0]['properties'].update({'linux,initrd-start': struct.pack('>I', 0x98000000), - 'linux,initrd-end': struct.pack('>I', 0x98000010)}) - old = cascadia.write_fdt(value, [(0x98000000, 16), (0x9f000000, 4096)]) - new = cascadia.chosen(old, 'new', 0x98000000, 32) - self.assertEqual(cascadia.read_fdt(new)[1], [(0x9f000000, 4096), (0x98000000, 32)]) - - def test_reserved_destination_refused(self): - old = cascadia.write_fdt(tree(), [(PROFILE['initrd_address'] + 4, 4096)]) - with self.assertRaisesRegex(ValueError, 'reserved memory'): - self.assemble(dtb=old) - def test_fdt_corruption_is_rejected(self): original = cascadia.write_fdt(tree()) for offset, value in ((4, len(original) + 1), (8, 0xffffffff), (12, 0), (16, 41), (20, 16)): @@ -188,8 +171,16 @@ def test_wrong_kernel_and_out_of_bounds_initramfs_refused(self): smaller = dict(PROFILE, initrd_limit=PROFILE['initrd_address'] + 8) with self.assertRaisesRegex(ValueError, 'destination'): self.assemble(loader=loader(smaller), described=smaller, elf=None) - with self.assertRaisesRegex(ValueError, 'one line'): - self.assemble(bootargs='a\nb') + + 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()) @@ -226,7 +217,7 @@ def test_cli_writes_receipt_and_refuses_overwrite(self): described = directory / 'profile.toml' described.write_text(''.join(f'{name} = {value}\n' for name, value in PROFILE.items())) out = directory / 'linux.bin' - command.extend(['--profile', str(described), '--bootargs', 'rdinit=/init', '--output', str(out)]) + command.extend(['--profile', str(described), '--output', str(out)]) first = subprocess.run(command, capture_output=True, text=True) self.assertEqual(first.returncode, 0, first.stderr) self.assertEqual(json.loads((directory / 'linux.bin.json').read_text())['image_sha256'], cascadia.digest(out.read_bytes())) From 8038c8a998ec2ad43306e5acb8418b1e4c8c3660 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:40:14 +0800 Subject: [PATCH 6/9] build: cross-compile the ARMv7 static tools Apple Silicon, and most ARM64 machines, cannot run 32-bit ARM code, so an ARMv7 container cannot build the tools there. They are now cross-compiled in a container of the host's own architecture (Containerfile.arm, with Debian's armhf GCC and musl), statically against musl as the arm64 tools are; the Debian armhf BusyBox package is only unpacked. The arm64 tools build as before. install-toolchain --cross arm lists or installs the extra packages. Assisted-by: LLM --- boot/initramfs/recovery/Containerfile.arm | 3 +++ boot/initramfs/recovery/build-tools | 14 ++++++---- .../recovery/build-tools-in-container | 27 ++++++++++++++----- boot/initramfs/recovery/install-toolchain | 12 +++++++-- src/orchard/build.py | 2 +- 5 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 boot/initramfs/recovery/Containerfile.arm 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 dd8a7fa5..426e4931 100755 --- a/boot/initramfs/recovery/build-tools +++ b/boot/initramfs/recovery/build-tools @@ -58,11 +58,15 @@ for component, filename in (('busybox', 'busybox.deb'), pathlib.Path(temporary.name).rename(target) print(f'Verified {component}: {target}') PY -image=localhost/orchard-hardware-recovery-tools:bookworm -[[ "$arch" == arm64 ]] || image=localhost/orchard-hardware-recovery-tools-arm:bookworm -"$python" "$here/../../../src/orchard/buildenv.py" build --arch "$arch" --file "$here/Containerfile" \ - --image "$image" --context "$here" -"$python" "$here/../../../src/orchard/buildenv.py" run --arch "$arch" \ +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 8186df88..7ae1f863 100755 --- a/boot/initramfs/recovery/build-tools-in-container +++ b/boot/initramfs/recovery/build-tools-in-container @@ -13,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/" ) @@ -32,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 ) @@ -68,13 +83,13 @@ 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" \ "$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 diff --git a/boot/initramfs/recovery/install-toolchain b/boot/initramfs/recovery/install-toolchain index 6c33aaad..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 python3 xz-utils) -if [[ ${1:-} == --packages ]]; then printf '%s\n' "${packages[@]}"; exit 0; fi +# --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/src/orchard/build.py b/src/orchard/build.py index a11eeecc..150838ba 100644 --- a/src/orchard/build.py +++ b/src/orchard/build.py @@ -490,7 +490,7 @@ 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', + ('build-tools', 'build-tools-in-container', 'Containerfile', 'Containerfile.arm', 'install-toolchain', 'upstream.lock', 'check-tools.py', 'orchard-write.c')] + ['src/orchard/buildenv.py'] From 175254340537b8d6ada793512df53f4271471003 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:40:14 +0800 Subject: [PATCH 7/9] initrd: load the USB gadget's modules where the kernel has them as modules orchard's ARMv7 kernel builds the configfs gadget functions as modules (NCM today; with the legacy g_cdc gadget off, libcomposite and ACM too). The initrd now carries libcomposite, usb_f_ncm and usb_f_acm with their dependencies when the kernel has them as modules, and loads them before the storage modules, so USB serial and SSH come up on install and installed boots. A module that fails to load costs USB only, never the boot. The arm64 kernels build the gadget in: their initrd has no list and loads nothing new. Assisted-by: LLM --- boot/initramfs/root/init | 5 +++++ src/orchard/image/platform.py | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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/src/orchard/image/platform.py b/src/orchard/image/platform.py index 04c00c66..b39ae7bb 100644 --- a/src/orchard/image/platform.py +++ b/src/orchard/image/platform.py @@ -34,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/') @@ -96,7 +100,8 @@ def initrd(modules, release, busybox, *, hardware=None, storage_modules=STORAGE_ 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)}') - wanted, pending = [], [by_name[name.replace('_', '-')] for name in loaded] + 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: @@ -108,6 +113,8 @@ def initrd(modules, release, busybox, *, hardware=None, storage_modules=STORAGE_ 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}' From 654247b9fbe48273c013a6586452c2d589192cab Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:40:14 +0800 Subject: [PATCH 8/9] install: 32-bit boards boot recovery-less, from the Cascadia loader As on 64-bit, the host builds one file from the platform layer's kernel, initrd and board DTB plus the boot's add-on (writer, Dropbear, e2fsprogs tools, this boot's keys); on a 32-bit board the board's Cascadia loader takes m1n1's place. The loader copies the initrd and the add-on to the board's initrd address before it turns off the MMU, the DTB's placeholders are set to it, and the board's own boot arguments stay. The addresses come from orchard's device data (devices/BOARD/cascadia.toml), so no release names them. Handing the file to a patched iBEC is not orchard's yet, and says so. orchard publishes for 32-bit roots next to the unchanged platforms.toml: platforms-armv7.toml lists the ARMv7 platform layers (every 32-bit DTB), install-tools-armv7.tar.gz and per board cascadia-BOARD.bin. orchard.inputs resolves a 32-bit root's kernels, tools and loaders. A 32-bit release manifest names its boards' loaders under [cascadia] instead of m1n1 and Pongo. 64-bit payloads, indexes and releases are unchanged. Contract change (docs/distribution-contract.md): orchard.inputs --loader, platforms_index --architecture and --loader, cascadia.py by path. Assisted-by: LLM --- docs/distribution-contract.md | 16 +- schemas/v2/install.schema.json | 27 ++- src/orchard/image/platforms_index.py | 78 ++++++-- src/orchard/inputs.py | 85 ++++++++- src/orchard/install_direct.py | 41 ++++- src/orchard/manifests.py | 2 + src/orchard/releases.py | 11 +- src/orchard/service.py | 5 +- tests/test_armv7_direct.py | 261 +++++++++++++++++++++++++++ tests/test_distribution_contract.py | 8 +- 10 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 tests/test_armv7_direct.py 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/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/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('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_distribution_contract.py b/tests/test_distribution_contract.py index 0aff2f4d..4f66537f 100644 --- a/tests/test_distribution_contract.py +++ b/tests/test_distribution_contract.py @@ -14,9 +14,11 @@ # Every command of the contract and the options a distribution may rely on. COMMANDS = { - 'orchard.inputs': ['--output', '--platform', '--kernel-dir', '--m1n1', '--pongo', '--tools', '--kernel-tree'], + 'orchard.inputs': ['--output', '--platform', '--kernel-dir', '--m1n1', '--pongo', '--tools', '--kernel-tree', + '--loader'], 'orchard.image.platform': ['--output', '--kernel-dir', '--busybox', '--receipt'], - 'orchard.image.platforms_index': ['--release', '--inputs', '--kernels', '--tools', '--commit', '--platform'], + 'orchard.image.platforms_index': ['--release', '--inputs', '--kernels', '--tools', '--commit', '--platform', + '--architecture', '--loader'], 'orchard.image.root_image': ['--output'], 'orchard.image.recovery': ['--dtb', '--loader', '--logo', '--kernel', '--modules-dir', '--busybox', '--dropbear', '--dropbearkey', '--wpa-supplicant', '--wpa-cli', '--iw', '--base', '--base-receipt', @@ -44,7 +46,7 @@ def test_every_command_keeps_its_options(self): self.assertIn(option, release) def test_the_by_path_scripts_need_no_orchard_package(self): - for script in ('src/orchard/image/assemble.py', 'src/orchard/buildenv.py'): + for script in ('src/orchard/image/assemble.py', 'src/orchard/image/cascadia.py', 'src/orchard/buildenv.py'): with self.subTest(script=script): result = subprocess.run([sys.executable, '-I', str(ROOT / script), '--help'], capture_output=True, text=True, cwd=tempfile.gettempdir()) From 9e038817e83ec8b38e24e17cad8e853b5344132e Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:41:21 +0800 Subject: [PATCH 9/9] ci: publish the 32-bit index with every nightly The tools job also cross-compiles the ARMv7 tools and builds a Cascadia loader for each board with a devices/BOARD/cascadia.toml; the publication writes platforms-armv7.toml next to platforms.toml and lists it in SHA256SUMS. ci/install-toolchains recovery-arm adds the armhf cross toolchain. Assisted-by: LLM --- .github/workflows/build.yml | 21 +++++++++++++++++++-- ci/install-toolchains | 8 ++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) 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/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