diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de1d4b2b..85ceccf3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,20 @@ the follow-ups under "Open work", so you can pick them up from there. Patches are applied in the layer order of `kernel/series/`; within a directory, in file-name order. +Platforms with different upstream bases can share a subsystem directory. +An adjacent `.patch.toml` restricts a patch to named platforms: + +```toml +schema = 1 +platforms = ["apple-armv7"] +``` + +A plain directory entry includes its unscoped patches and those scoped to +that platform. `scoped patches/drivers/usb` includes only patches explicitly +scoped to the platform. Use this form when adding a shared directory to a +series whose upstream base differs from the existing patches. Each patch +must be selected once; repeated and overlapping layers are refused. + - `kernel/patches/drivers//`: changes to drivers and their bindings, including new drivers. Use the subsystem directory that matches the kernel's (`spi`, `input`, `power`, …); add one when none fits. @@ -70,6 +84,12 @@ directory, in file-name order. Number a new patch with the next free number in its directory. Don't renumber existing patches. +After testing the source branch on hardware, use +`tools/kernel-tree/export.py --platform ` to write scoped patches +and add missing scoped layers. Repeat `--platform` for each platform that +shares the generated kernel branch. The export keeps subsystem directories +and assigns the next free patch number. + A driver patch filed under `devices/` hides that it changes behaviour on other devices, which is why the split matters. 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/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/init b/boot/initramfs/recovery/init index 4b6fd2fa..e8802263 100755 --- a/boot/initramfs/recovery/init +++ b/boot/initramfs/recovery/init @@ -17,19 +17,30 @@ case "$board" in apple,*) ;; *) echo "Not an Apple device for Orchard recovery: $board"; exec sh ;; esac -/sbin/recovery-usb -ip address add 172.16.42.1/30 dev usb0 -ip link set usb0 up -udhcpd -f /etc/udhcpd.conf & -echo "${board#apple,} recovery: USB serial ready; provision an SSH key before SSH starts." -# Respawn unconditionally: a USB re-enumeration hangs up the shell, which then -# exits non-zero, and serial is the only way to provision SSH. +# Serial survives networking failures and repeated USB enumeration. ( while true; do getty -n -l /bin/sh ttyGS0 115200 vt100; sleep 1; done ) & -# Internal storage comes up read-only in the background (storage-fence); the -# host installs over SSH once /run/orchard/storage-ready exists. -/usr/libexec/orchard/storage-fence > /run/orchard/storage.log 2>&1 & +if ! /sbin/recovery-usb > /run/orchard/usb.log 2>&1; then + echo 'Recovery USB setup failed; inspect /run/orchard/usb.log.' + while :; do sleep 60; done +fi +if ip address add 172.16.42.1/30 dev usb0 && ip link set usb0 up; then + udhcpd -f /etc/udhcpd.conf & +else + echo 'Recovery networking failed; USB serial remains available.' +fi +echo "${board#apple,} recovery: USB serial ready; provision an SSH key before SSH starts." +if storage=$(/usr/bin/python3 /usr/libexec/orchard/policy.py storage); then + if [ "$storage" = automatic ]; then + /usr/libexec/orchard/storage-fence > /run/orchard/storage.log 2>&1 & + else + echo manual > /run/orchard/storage-manual + echo 'Storage waits for a manual request after serial and authenticated SSH are ready.' + fi +else + echo 'Recovery storage policy failed; storage remains unloaded.' +fi # After an install, `orchard-device start` asks for the new root: if its boot # entry boots this same kernel, PID 1 switches into it, so the first start # needs no second DFU boot. Later starts go through the entry (orchard launch). 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/boot/initramfs/recovery/usb-gadget b/boot/initramfs/recovery/usb-gadget index e5b7a826..4356b57a 100755 --- a/boot/initramfs/recovery/usb-gadget +++ b/boot/initramfs/recovery/usb-gadget @@ -1,6 +1,9 @@ #!/bin/sh # USB NCM networking and ACM serial, restricted to the attached host. set -eu +network=$(/usr/bin/python3 /usr/libexec/orchard/policy.py network) +modules=$(/usr/bin/python3 /usr/libexec/orchard/policy.py modules) +for module in $modules; do modprobe "$module"; done mkdir -p /sys/kernel/config mount -t configfs -o nodev,noexec,nosuid configfs /sys/kernel/config g=/sys/kernel/config/usb_gadget/linux-recovery @@ -16,8 +19,8 @@ echo 'Orchard Recovery' > "$g/strings/0x409/product" printf '%s\n' "$identity" > "$g/strings/0x409/serialnumber" echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration" echo 500 > "$g/configs/c.1/MaxPower" -mkdir "$g/functions/ncm.usb0" "$g/functions/acm.usb0" -ln -s "$g/functions/ncm.usb0" "$g/configs/c.1/ncm.usb0" +mkdir "$g/functions/$network.usb0" "$g/functions/acm.usb0" +ln -s "$g/functions/$network.usb0" "$g/configs/c.1/$network.usb0" ln -s "$g/functions/acm.usb0" "$g/configs/c.1/acm.usb0" set -- /sys/class/udc/* [ "$#" -eq 1 ] && [ -e "$1" ] diff --git a/boot/initramfs/root/init b/boot/initramfs/root/init index 66008ab2..3110b65f 100755 --- a/boot/initramfs/root/init +++ b/boot/initramfs/root/init @@ -1,16 +1,67 @@ #!/bin/busybox sh # orchard's initrd for installed systems: load storage, mount the root that # root=PARTUUID= names, start its init. The same for every distribution and -# init system; only BusyBox and the platform's storage modules are inside. +# init system. LwVM platforms also carry the static mapper and owner SSH rescue. export PATH=/bin /bin/busybox --install -s /bin mount -t proc -o nodev,noexec,nosuid proc /proc mount -t sysfs -o nodev,noexec,nosuid sysfs /sys mount -t devtmpfs -o mode=0755,nosuid dev /dev +# The install add-on owns its RAM mounts and USB session until it returns. +install_boot= +[ ! -e /install/run ] || install_boot=1 +if [ -z "$install_boot" ]; then + mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs /run + mkdir -p /dev/pts + mount -t devpts -o mode=0620,ptmxmode=0666 devpts /dev/pts +fi exec /dev/console 2>&1 +# Available before storage discovery, so a rejected header pair leaves USB +# serial and key-provisioned SSH in RAM. Root mounting never supplies the key. +usb_gadget() { + g=/sys/kernel/config/usb_gadget/orchard-system + mkdir -p /sys/kernel/config && mount -t configfs -o nodev,noexec,nosuid configfs /sys/kernel/config 2>/dev/null + [ ! -e "$g" ] || return 0 + board=$(tr '\000' '\n' < /proc/device-tree/compatible | head -1) + mkdir "$g" && + echo 0x05ac > "$g/idVendor" && + echo 0x4145 > "$g/idProduct" && + mkdir -p "$g/strings/0x409" "$g/configs/c.1/strings/0x409" && + echo 'Orchard Linux' > "$g/strings/0x409/manufacturer" && + echo "${1:-Orchard installed root}" > "$g/strings/0x409/product" && + echo "BOOT:$(cat /proc/sys/kernel/random/boot_id) BOARD:${board#apple,}" > "$g/strings/0x409/serialnumber" && + echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration" && + echo 500 > "$g/configs/c.1/MaxPower" && + mkdir "$g/functions/ncm.usb0" "$g/functions/acm.usb0" && + ln -s "$g/functions/ncm.usb0" "$g/configs/c.1/ncm.usb0" && + ln -s "$g/functions/acm.usb0" "$g/configs/c.1/acm.usb0" && + basename "$(ls -d /sys/class/udc/* | head -1)" > "$g/UDC" +} +rescue_pid= dhcp_pid= +if [ -x /bin/orchard-lwvm ] && [ -z "$install_boot" ]; then + usb_gadget || echo 'orchard initrd: USB gadget setup failed; console recovery remains available' + mkdir -p /run/recovery /run/orchard/rescue + chmod 700 /run/orchard /run/orchard/rescue + cp /bin/busybox /run/orchard/rescue/busybox + cp /bin/orchard-acm-rescue /run/orchard/rescue/acm-rescue + for tool in dropbear dropbearkey recovery-authorize-key recovery-start-ssh; do + cp "/bin/$tool" "/run/orchard/rescue/$tool" + done + /run/orchard/rescue/busybox --install -s /run/orchard/rescue + ip link set lo up + if ip address add 172.16.42.1/30 dev usb0 2>/dev/null && ip link set usb0 up; then + udhcpd -f /etc/udhcpd.conf & + dhcp_pid=$! + fi + /run/orchard/rescue/busybox sh /run/orchard/rescue/acm-rescue & + rescue_pid=$! + echo 'orchard initrd: USB serial ready; recovery-authorize-key and recovery-start-ssh enable owner SSH.' +fi + stop() { echo "orchard initrd: $1" + [ ! -s /run/lwvm-root.log ] || cat /run/lwvm-root.log echo 'Starting a shell on the console; the system did not start.' exec sh } @@ -27,12 +78,19 @@ for argument in $(cat /proc/cmdline); do done for module in $(cat /etc/orchard/modules); do - modprobe "$module" || stop "could not load $module" + orchard-load-storage "$module" || stop "could not load $module with its requested policy" done # An install boot: orchard's add-on, appended to this initrd by the CLI, writes the # system with the host, then sets root to what it wrote. Normal boots don't have it. -[ ! -e /install/run ] || . /install/run +if [ -n "$install_boot" ]; then + . /install/run || stop 'the install add-on did not finish' + # The add-on retires its session and unmounts these before returning. + # Installed init receives a fresh RAM state and a working devpts mount. + mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs /run || stop 'could not prepare /run' + mkdir -p /dev/pts || stop 'could not prepare /dev/pts' + mount -t devpts -o mode=0620,ptmxmode=0666 devpts /dev/pts || stop 'could not mount devpts' +fi [ -n "$root" ] || stop 'the boot entry names no root=PARTUUID=' # gpt_partuuid DISK SECTOR_SIZE NUMBER: the unique GUID of GPT entry NUMBER, @@ -51,14 +109,22 @@ gpt_partuuid() { device= seconds=0 while [ -z "$device" ]; do + if [ -x /bin/orchard-lwvm ]; then + for disk in /dev/applesftl*; do + [ -b "$disk" ] || continue + candidate=$(orchard-lwvm map --disk "$disk" --root "$root" --mode "$mode" 2>/run/lwvm-root.log) || continue + [ -z "$device" ] || stop "more than one LwVM provider has root $root" + device=$candidate + done + fi for number in /sys/class/block/*/partition; do [ -e "$number" ] || continue part=${number%/partition} disk=$(basename "$(dirname "$(readlink -f "$part")")") sector=$(cat "/sys/class/block/$disk/queue/logical_block_size") if [ "$(gpt_partuuid "/dev/$disk" "$sector" "$(cat "$number")")" = "$root" ]; then + [ -z "$device" ] || stop "more than one provider has root $root" device=/dev/${part##*/} - break fi done [ -n "$device" ] && break @@ -70,32 +136,54 @@ done mkdir -p /newroot mount ${fstype:+-t "$fstype"} -o "$mode${flags:+,$flags}" "$device" /newroot || stop "could not mount $device" [ -x "/newroot$init" ] || [ -L "/newroot$init" ] || stop "the root has no $init" - -# USB for the host: network (usb0) and a serial console (ttyGS0), named after -# the system, so `orchard devices` sees it. What runs on them is the system's. -usb_gadget() { - g=/sys/kernel/config/usb_gadget/orchard-system - mkdir -p /sys/kernel/config && mount -t configfs -o nodev,noexec,nosuid configfs /sys/kernel/config 2>/dev/null - [ ! -e "$g" ] || return 0 +if [ ! -x /bin/orchard-lwvm ]; then name=$(sed -n 's/^NAME=//p' /newroot/etc/os-release /newroot/usr/lib/os-release 2>/dev/null | head -1 | tr -d "\"'") - board=$(tr '\000' '\n' < /proc/device-tree/compatible | head -1) - mkdir "$g" && - echo 0x05ac > "$g/idVendor" && - echo 0x4145 > "$g/idProduct" && - mkdir -p "$g/strings/0x409" "$g/configs/c.1/strings/0x409" && - echo 'Orchard Linux' > "$g/strings/0x409/manufacturer" && - echo "${name:-Linux}" > "$g/strings/0x409/product" && - echo "BOOT:$(cat /proc/sys/kernel/random/boot_id) BOARD:${board#apple,}" > "$g/strings/0x409/serialnumber" && - echo 'USB network and serial' > "$g/configs/c.1/strings/0x409/configuration" && - echo 500 > "$g/configs/c.1/MaxPower" && - mkdir "$g/functions/ncm.usb0" "$g/functions/acm.usb0" && - ln -s "$g/functions/ncm.usb0" "$g/configs/c.1/ncm.usb0" && - ln -s "$g/functions/acm.usb0" "$g/configs/c.1/acm.usb0" && - basename "$(ls -d /sys/class/udc/* | head -1)" > "$g/UDC" -} -usb_gadget || echo 'orchard initrd: no USB gadget; continuing without USB' + usb_gadget "${name:-Linux}" || echo 'orchard initrd: no USB gadget; continuing without USB' +fi -for name in dev proc sys; do - mount --move "/$name" "/newroot/$name" +# Prepare RAM rescue before the installed init starts its own services. +# BusyBox switch_root needs PID1; no child performs the mount handoff. +if [ -n "$rescue_pid" ]; then + # systemd can supervise a replacement from RAM after switch_root. Other + # init systems retain their own normal USB serial service contract. + if [ -x /newroot/usr/lib/systemd/systemd ] && [ "$init" = /sbin/init ]; then + mkdir -p /run/systemd/system/sysinit.target.wants + cat > /run/systemd/system/orchard-acm-rescue.service <<'UNIT' +[Unit] +Description=Orchard physical USB RAM rescue +DefaultDependencies=no +Before=basic.target +IgnoreOnIsolate=yes + +[Service] +Type=simple +ExecStart=/run/orchard/rescue/busybox sh /run/orchard/rescue/acm-rescue +Restart=always +RestartSec=1 +TimeoutStopSec=2 +KillMode=control-group +UNIT + ln -s ../orchard-acm-rescue.service /run/systemd/system/sysinit.target.wants/orchard-acm-rescue.service + fi +fi +shell_pid=$(cat /run/orchard/rescue/shell.pid 2>/dev/null) +ssh_pid=$(cat /run/recovery/dropbear.pid 2>/dev/null) +moved= +handoff_mounts='dev proc sys run' +for name in $handoff_mounts; do + mkdir -p "/newroot/$name" || stop "the root lacks /$name" +done +for name in $handoff_mounts; do + if ! mount --move "/$name" "/newroot/$name"; then + for previous in $moved; do + mount --move "/newroot/$previous" "/$previous" || true + done + stop "could not move /$name into the root" + fi + moved="$name $moved" done +[ -z "$rescue_pid" ] || kill "$rescue_pid" 2>/dev/null || true +[ -z "$shell_pid" ] || /bin/busybox kill -KILL "-$shell_pid" 2>/dev/null || true +[ -z "$dhcp_pid" ] || kill "$dhcp_pid" 2>/dev/null || true +[ -z "$ssh_pid" ] || kill "$ssh_pid" 2>/dev/null || true exec switch_root /newroot "$init" diff --git a/boot/initramfs/root/load-storage b/boot/initramfs/root/load-storage new file mode 100755 index 00000000..c15085c6 --- /dev/null +++ b/boot/initramfs/root/load-storage @@ -0,0 +1,65 @@ +#!/bin/busybox sh +# SPDX-License-Identifier: GPL-2.0-only +# Forward an explicit SFTL operating policy to BusyBox modprobe. A root's rw +# mount option never changes the storage backend's default-off write gate. +set -efu +fail() { echo "orchard storage policy: $1" >&2; exit 1; } +[ "$#" -ge 1 ] && [ "$#" -le 2 ] || fail 'expected MODULE [CMDLINE]' +module=$1 +cmdline=${2:-/proc/cmdline} +case "$module" in ''|[!A-Za-z0-9_]*|*[!A-Za-z0-9_+-]*) fail 'invalid module name' ;; esac +# BusyBox can resolve a bare name to a compiled-in applet before PATH. Use +# the installed executable path so the final argv also has a direct test seam. +probe= +saved_ifs=$IFS +IFS=: +for directory in $PATH; do + if [ -n "$directory" ] && [ -x "$directory/modprobe" ]; then + probe=$directory/modprobe + break + fi +done +IFS=$saved_ifs +[ -n "$probe" ] || fail 'modprobe is unavailable' +case "$module" in +apple-sftl|apple_sftl) ;; +*) exec "$probe" "$module" ;; +esac +write= reads= lifetime= +IFS= read -r arguments < "$cmdline" || fail 'could not read the kernel command line' +for argument in $arguments; do + case "$argument" in + apple_sftl.*|apple-sftl.*) + option=${argument#*.} + key=${option%%=*} + value=${option#*=} + [ "$value" != "$option" ] || fail 'storage policy needs a value' + case "$key" in + write_enable) + [ -z "$write" ] || fail 'repeated write_enable' + case "$value" in 0|1) write=$value ;; *) fail 'write_enable must be 0 or 1' ;; esac + ;; + max_page_reads|max_io_time_ms) + case "$value" in ''|0*|*[!0-9]*) fail 'storage limits must be positive canonical decimal integers' ;; esac + [ "${#value}" -le 10 ] && [ "$value" -le 4294967295 ] || fail 'storage limit exceeds uint32' + if [ "$key" = max_page_reads ]; then + [ -z "$reads" ] || fail 'repeated max_page_reads' + reads=$value + else + [ -z "$lifetime" ] || fail 'repeated max_io_time_ms' + lifetime=$value + fi + ;; + *) fail 'unsupported SFTL operating parameter' ;; + esac + ;; + esac +done +if [ "$write" = 1 ]; then + [ -n "$reads" ] && [ -n "$lifetime" ] || fail 'write opt-in also requires explicit read and lifetime limits' +fi +set -- "$module" +[ -z "$write" ] || set -- "$@" "write_enable=$write" +[ -z "$reads" ] || set -- "$@" "max_page_reads=$reads" +[ -z "$lifetime" ] || set -- "$@" "max_io_time_ms=$lifetime" +exec "$probe" "$@" diff --git a/boot/lwvm/README.md b/boot/lwvm/README.md new file mode 100644 index 00000000..67f76e57 --- /dev/null +++ b/boot/lwvm/README.md @@ -0,0 +1,93 @@ +# LwVM installed-root mapper + +`orchard-lwvm` reads Apple's logical volume map from a whole logical storage +provider. The kernel backend supplies flash geometry, ECC and the current FTL +mapping. This helper handles the LwVM volume layer above that provider. + +Build a static executable with `boot/lwvm/build --arch arm --output DIRECTORY`. +The same producer accepts `arm64`. `orchard.build.prepare_lwvm` caches the +executable by its source inputs and target architecture. + +``` +orchard-lwvm inspect --disk image.raw --root UUID --json +orchard-lwvm map --disk /dev/applesftl0 --root UUID --mode ro +orchard-lwvm map --disk /dev/applesftl0 --root UUID --mode rw --json +orchard-lwvm unmap --disk /dev/applesftl0 --root UUID --mode rw --extent START:SECTORS:PHYSICAL +``` + +`inspect` accepts a regular image or block device. `map` requires a block +device and root privileges. Its default output is the verified `/dev/dm-N` +path. A JSON receipt has schema 1, kind `orchard-lwvm-root`, canonical +`root_uuid`, zero-based `partition_index`, `header_crc32`, `media_bytes`, +`chunk_bytes`, root `bytes`, `disk` major/minor and ordered `extents`. +Each extent contains `start_sector`, `sectors` and `physical_sector` in +512-byte sectors. A mapped receipt adds `mapping` with `name`, `uuid`, +`device` and `mode`. Regular images have null disk numbers. + +The helper never programs flash or changes either LwVM header. A writable DM +table also requires a writable kernel provider; it grants no additional FTL +write capability. Existing mappings are reused only when their complete +linear table, provider identity, DM UUID and access mode match. Failure while +creating a new map removes that map. An existing mismatch remains untouched. + +Cleanup uses `unmap` with the original provider path, root UUID, access mode +and one `--extent START:SECTORS:PHYSICAL` for every ordered receipt extent. +It verifies the complete table and DM identity before removing an unused +map. A missing map succeeds; a busy or mismatching map remains in place. +This operation only stats the original provider and reads DM metadata. It +opens no storage provider and reads no headers, allowing cleanup after the +reader budget is exhausted. Recovery needs no external `dmsetup` for this. + +## Accepted format + +The format derives from native LwVM header validation and geometry observed +in the iOS 9.3.5 storage provider, with the prior Orchard image tooling used +as a crosscheck. The tests construct independent synthetic headers. + +The accepted signature is `6a9088cf8afd630ae351e24887e0b98b`. Both 4096-byte +header copies must pass their CRC32 and agree byte for byte. CRC32 uses the +whole header with bytes `0x2c..0x2f` zeroed, matching the standard zlib CRC32. +The media size at `0x20` must equal the provider's actual byte length. +The nonzero media UUID occupies `0x10..0x1f`. + +To derive chunk size, take `(media_bytes - 1) >> 10`, then choose the power of +two whose exponent is that value's bit length. The accepted value fits 32 +bits. The mapper requires at least 8192 bytes per chunk so the two 4096-byte +header ranges remain disjoint. The primary header starts at +zero; the backup starts at `chunk_bytes - 4096`. No measured device size or +owner identity selects the geometry. + +Partition count at `0x28` is between 1 and 8. Each 128-byte descriptor starts +at `0x200 + index * 0x80`: type UUID, identity UUID, then little-endian 64-bit +virtual start, virtual end and attributes. UUID bytes use canonical order. +Virtual bounds determine volume length. They do not locate physical data. +The requested root must carry the standard Linux filesystem type UUID +`0fc63daf-8483-4772-8e79-3d69d8477de4` and zero attributes. This excludes +the encrypted Data volume from the mapper. + +The 1024 little-endian words at `0x800` locate physical chunks. A word's high +nibble selects its descriptor; its low 12 bits give that descriptor's logical +chunk index. Every descriptor requires exactly one chunk for each logical +index covering its length. Owned chunks must fit wholly within the provider. +Chunk zero belongs to metadata. The accepted special markers are `0xf000` +at index zero, `0xf111` for free complete chunks, and `0xf3ff` beyond the last +complete chunk. Unknown markers, missing ownership, duplicate logical chunks +and overlapping virtual descriptors stop mapping. + +The helper orders extents by logical chunk index and merges physically +adjacent runs. It clips the final run to the descriptor's exact byte length. +Consequently a fragmented volume does not expose gaps or neighbouring data. +It refuses a damaged or divergent header pair without attempting repair. + +## Validation boundary + +`tests/test_lwvm.py` compiles and executes the C parser against sparse images. +These tests establish format checks and extent generation. Kernel compilation, +synthetic Linux mapping checks and device boot qualification are separate +results. Device feature flags remain gated on hardware qualification. + +`boot/lwvm/check-linux --helper PATH` runs the actual helper against a +temporary image on a disposable Linux worker as root. It allocates its own +loop device and map, checks fragmented reads and writes, tests exact reuse +and mismatch refusal, then removes its map and loop association. The ARMv7 +static helper has passed this check under Linux's compat ioctl path. diff --git a/boot/lwvm/build b/boot/lwvm/build new file mode 100755 index 00000000..c693a4c6 --- /dev/null +++ b/boot/lwvm/build @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Build the static LwVM root mapper for one ARM architecture.""" +import argparse +import importlib.util +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'src')) +from orchard import buildenv + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--arch', choices=('arm', 'arm64'), required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + if output.is_symlink() or (output.exists() and any(output.iterdir())): + parser.error('Output must be a new or empty directory') + output.mkdir(parents=True, exist_ok=True) + recipe = ROOT / 'boot/initramfs/recovery' + image = 'localhost/orchard-hardware-recovery-tools' + ('-arm' if args.arch == 'arm' else '') + ':bookworm' + buildenv.build(image, recipe / 'Containerfile', recipe, arch=args.arch) + buildenv.run(image, ['sh', '/src/boot/lwvm/compile', '/out'], + [(ROOT, '/src', True), (output, '/out', False)], arch=args.arch) + spec = importlib.util.spec_from_file_location('check_tools', recipe / 'check-tools.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.check(output / 'orchard-lwvm', args.arch) + + +if __name__ == '__main__': + main() diff --git a/boot/lwvm/check-linux b/boot/lwvm/check-linux new file mode 100755 index 00000000..17f58815 --- /dev/null +++ b/boot/lwvm/check-linux @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-only +"""Exercise the real mapper with a disposable sparse image and Linux loop device.""" +import argparse +import errno +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import uuid + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'src')) +spec = importlib.util.spec_from_file_location('fixture', ROOT / 'tests/test_lwvm.py') +fixture = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixture) + + +def run(*argv, check=True): + return subprocess.run(argv, check=check, text=True, capture_output=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--helper', type=Path, required=True) + args = parser.parse_args() + if sys.platform != 'linux' or os.geteuid() != 0: + parser.error('Run as root on a disposable Linux build worker') + identity = str(uuid.uuid4()) + name = 'orchard-lwvm-' + identity + loop = None + with tempfile.TemporaryDirectory(prefix='orchard-lwvm-check-') as temporary: + image = Path(temporary) / 'media.img' + h = fixture.header() + h[0x310:0x320] = uuid.UUID(identity).bytes + fixture.crc(h) + with image.open('wb') as out: + out.truncate(fixture.MEDIA) + out.write(h) + out.seek(fixture.CHUNK - 4096) + out.write(h) + for physical, value in ((1, b'S'), (3, b'D'), (10, b'A'), (6, b'B'), (7, b'C')): + out.seek(physical * fixture.CHUNK) + out.write(value * fixture.CHUNK) + try: + loop = run('losetup', '--find', '--show', str(image)).stdout.strip() + def map_root(mode='ro', check=True): + return run(str(args.helper), 'map', '--disk', loop, '--root', identity, + '--mode', mode, '--json', check=check) + def unmap(receipt, check=True): + command = [str(args.helper), 'unmap', '--disk', loop, '--root', identity, + '--mode', receipt['mapping']['mode']] + for extent in receipt['extents']: + command += ['--extent', f'{extent["start_sector"]}:{extent["sectors"]}:{extent["physical_sector"]}'] + return run(*command, check=check) + first = json.loads(map_root().stdout) + second = json.loads(map_root().stdout) + assert first == second, 'exact mapping reuse changed the receipt' + mapped = Path(first['mapping']['device']) + expected = b'A' * fixture.CHUNK + b'B' * fixture.CHUNK + b'C' * 8192 + with mapped.open('rb', buffering=0) as stream: + assert stream.read() == expected, 'noncontiguous logical read is incorrect' + descriptor = None + try: + descriptor = os.open(mapped, os.O_WRONLY) + os.write(descriptor, b'R' * 512) + except OSError as failure: + assert failure.errno in (errno.EROFS, errno.EACCES, errno.EPERM) + else: + raise AssertionError('read-only map accepted a write') + finally: + if descriptor is not None: + os.close(descriptor) + assert map_root('rw', check=False).returncode, 'access-mode mismatch was reused' + assert json.loads(map_root().stdout) == first, 'failed reuse changed the live map' + with mapped.open('rb', buffering=0): + assert unmap(first, check=False).returncode, 'an open mapping was removed' + altered = json.loads(json.dumps(first)) + altered['extents'][0]['physical_sector'] += 1 + assert unmap(altered, check=False).returncode, 'a mismatching expected table was removed' + # Cleanup reads DM metadata only, even when the current headers + # can no longer be read or validated through the provider. + with image.open('r+b', buffering=0) as stream: + stream.write(bytes(4096)) + stream.seek(fixture.CHUNK - 4096) + stream.write(bytes(4096)) + unmap(first) + unmap(first) + with image.open('r+b', buffering=0) as stream: + stream.write(h) + stream.seek(fixture.CHUNK - 4096) + stream.write(h) + writable = json.loads(map_root('rw').stdout) + with open(writable['mapping']['device'], 'r+b', buffering=0) as stream: + stream.seek(fixture.CHUNK) + stream.write(b'W' * 512) + os.fsync(stream.fileno()) + with image.open('rb', buffering=0) as stream: + for physical, value in ((1, b'S'), (3, b'D'), (10, b'A')): + stream.seek(physical * fixture.CHUNK) + assert stream.read(512) == value * 512, 'write escaped the Linux extent' + stream.seek(6 * fixture.CHUNK) + assert stream.read(512) == b'W' * 512, 'write missed the Linux physical extent' + unmap(writable) + # Reusing a name/UUID with a different table must fail without removal. + table = f'0 {first["bytes"] // 512} linear {loop} {11 * fixture.CHUNK // 512}' + run('dmsetup', 'create', name, '--uuid', 'ORCHARD-LWVM-' + identity, '--table', table) + assert map_root('rw', check=False).returncode, 'foreign table was reused' + assert run('dmsetup', 'info', name, check=False).returncode == 0, 'foreign map was removed' + assert unmap(writable, check=False).returncode, 'foreign table was removed by cleanup' + print('PASS: real DM create/reuse, ro/rw mismatch, fragmented reads, bounded writes, ' + 'verified unmap with invalid headers, busy/mismatch refusal and idempotent cleanup') + finally: + run('dmsetup', 'remove', name, check=False) + if loop: + run('losetup', '--detach', loop) + + +if __name__ == '__main__': + main() diff --git a/boot/lwvm/compile b/boot/lwvm/compile new file mode 100755 index 00000000..422004e3 --- /dev/null +++ b/boot/lwvm/compile @@ -0,0 +1,11 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +set -eu +out=$1 +source=${ORCHARD_SRC:-/src}/boot/lwvm +headers=$(mktemp -d) +trap 'rm -rf "$headers"' EXIT +ln -s /usr/include/linux /usr/include/asm-generic "$headers/" +ln -s "/usr/include/$(gcc -dumpmachine)/asm" "$headers/asm" +musl-gcc -std=c11 -Os -Wall -Wextra -Werror -static -isystem "$headers" \ + "$source/lwvm.c" "$source/main.c" -o "$out/orchard-lwvm" diff --git a/boot/lwvm/lwvm.c b/boot/lwvm/lwvm.c new file mode 100644 index 00000000..439a4450 --- /dev/null +++ b/boot/lwvm/lwvm.c @@ -0,0 +1,169 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#include "lwvm.h" +#include + +static const uint8_t magic[16] = { + 0x6a, 0x90, 0x88, 0xcf, 0x8a, 0xfd, 0x63, 0x0a, + 0xe3, 0x51, 0xe2, 0x48, 0x87, 0xe0, 0xb9, 0x8b, +}; +static const uint8_t linux_type[16] = { + 0x0f, 0xc6, 0x3d, 0xaf, 0x84, 0x83, 0x47, 0x72, + 0x8e, 0x79, 0x3d, 0x69, 0xd8, 0x47, 0x7d, 0xe4, +}; +static uint16_t le16(const uint8_t *p) +{ + return p[0] | (uint16_t)p[1] << 8; +} +static uint32_t le32(const uint8_t *p) +{ + return le16(p) | (uint32_t)le16(p + 2) << 16; +} +static uint64_t le64(const uint8_t *p) +{ + return le32(p) | (uint64_t)le32(p + 4) << 32; +} +static int fail(const char **error, const char *text) +{ + *error = text; + return -1; +} +static int zero_uuid(const uint8_t *uuid) +{ + static const uint8_t zero[16]; + return !memcmp(uuid, zero, 16); +} +static int hex(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +int lwvm_uuid_parse(const char *text, uint8_t uuid[16]) +{ + size_t i, cursor = 0; + if (strlen(text) != 36) return -1; + for (i = 0; i < 16; i++) { + int a, b; + if (cursor == 8 || cursor == 13 || cursor == 18 || cursor == 23) + if (text[cursor++] != '-') return -1; + a = hex(text[cursor++]); b = hex(text[cursor++]); + if (a < 0 || b < 0) return -1; + uuid[i] = a * 16 + b; + } + return zero_uuid(uuid) ? -1 : 0; +} +void lwvm_uuid_format(const uint8_t uuid[16], char text[37]) +{ + static const char digits[] = "0123456789abcdef"; + size_t i, cursor = 0; + for (i = 0; i < 16; i++) { + if (i == 4 || i == 6 || i == 8 || i == 10) text[cursor++] = '-'; + text[cursor++] = digits[uuid[i] >> 4]; + text[cursor++] = digits[uuid[i] & 15]; + } + text[cursor] = 0; +} +int lwvm_geometry(uint64_t media, uint64_t *chunk, const char **error) +{ + uint64_t shifted; + if (media % 4096 || media < 8192) + return fail(error, "media must be a multiple of 4096 bytes"); + shifted = (media - 1) >> 10; + if (!shifted || shifted > UINT32_MAX) + return fail(error, "unsupported LwVM media size"); + *chunk = 1; + while (shifted) { *chunk <<= 1; shifted >>= 1; } + if (*chunk < 2 * LWVM_HEADER_BYTES || media / *chunk < 2) + return fail(error, "unsupported LwVM chunk geometry"); + return 0; +} +static uint32_t header_crc(const uint8_t *header) +{ + uint32_t crc = UINT32_MAX; + size_t i, bit; + for (i = 0; i < LWVM_HEADER_BYTES; i++) { + crc ^= i >= 0x2c && i < 0x30 ? 0 : header[i]; + for (bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (0xedb88320U & (0U - (crc & 1))); + } + return ~crc; +} +int lwvm_parse(const uint8_t a[LWVM_HEADER_BYTES], const uint8_t b[LWVM_HEADER_BYTES], uint64_t media, + const uint8_t uuid[16], struct lwvm_root *root, const char **error) +{ + uint64_t chunk, starts[LWVM_PARTITIONS], ends[LWVM_PARTITIONS]; + uint16_t physical[LWVM_PARTITIONS][LWVM_CHUNKS]; + uint32_t counts[LWVM_PARTITIONS], needed[LWVM_PARTITIONS], count; + int selected = -1; + size_t i, j; + if (lwvm_geometry(media, &chunk, error)) return -1; + if (memcmp(a, magic, 16) || memcmp(b, magic, 16)) + return fail(error, "unsupported LwVM signature"); + if (le32(a + 0x2c) != header_crc(a) || le32(b + 0x2c) != header_crc(b)) + return fail(error, "LwVM header CRC mismatch"); + if (memcmp(a, b, LWVM_HEADER_BYTES)) + return fail(error, "LwVM header copies disagree"); + if (le64(a + 0x20) != media || zero_uuid(a + 0x10)) + return fail(error, "LwVM media identity or length is invalid"); + count = le32(a + 0x28); + if (!count || count > LWVM_PARTITIONS) + return fail(error, "LwVM partition count is outside 1..8"); + memset(physical, 0xff, sizeof(physical)); + memset(counts, 0, sizeof(counts)); + for (i = 0; i < count; i++) { + const uint8_t *p = a + 0x200 + i * 0x80; + starts[i] = le64(p + 32); ends[i] = le64(p + 40); + if (zero_uuid(p) || zero_uuid(p + 16) || starts[i] < 4096 || + starts[i] % 4096 || ends[i] % 4096 || ends[i] <= starts[i] || ends[i] > media) + return fail(error, "LwVM partition descriptor is invalid"); + for (j = 0; j < i; j++) { + if (!memcmp(p + 16, a + 0x210 + j * 0x80, 16)) + return fail(error, "duplicate LwVM partition UUID"); + if (starts[i] < ends[j] && starts[j] < ends[i]) + return fail(error, "overlapping LwVM virtual partitions"); + } + needed[i] = (ends[i] - starts[i] + chunk - 1) / chunk; + if (needed[i] > LWVM_CHUNKS) + return fail(error, "LwVM partition exceeds the chunk table"); + if (!memcmp(uuid, p + 16, 16)) selected = (int)i; + } + for (i = 0; i < LWVM_CHUNKS; i++) { + uint16_t word = le16(a + 0x800 + i * 2); + unsigned int owner = word >> 12, logical = word & 0xfff; + if (owner == 15) { + if ((i == 0 && word == 0xf000) || + (i > 0 && i < media / chunk && word == 0xf111) || + (i >= media / chunk && word == 0xf3ff)) continue; + return fail(error, "invalid LwVM special chunk marker"); + } + if (!i || i >= media / chunk || owner >= count || logical >= needed[owner]) + return fail(error, "LwVM chunk lies outside its partition or media"); + if (physical[owner][logical] != UINT16_MAX) + return fail(error, "duplicate LwVM logical chunk"); + physical[owner][logical] = i; + counts[owner]++; + } + for (i = 0; i < count; i++) + if (counts[i] != needed[i]) return fail(error, "incomplete LwVM partition chunk map"); + if (selected < 0) return fail(error, "requested LwVM root UUID was not found"); + if (memcmp(a + 0x200 + selected * 0x80, linux_type, 16) || + le64(a + 0x230 + selected * 0x80)) + return fail(error, "requested root must have Linux type and zero attributes"); + memset(root, 0, sizeof(*root)); + root->media_bytes = media; root->chunk_bytes = chunk; + root->bytes = ends[selected] - starts[selected]; + root->partition_index = selected; root->header_crc32 = le32(a + 0x2c); + memcpy(root->uuid, uuid, 16); + for (i = 0; i < needed[selected]; i++) { + uint64_t start = i * chunk / 512; + uint64_t size = (root->bytes / 512 - start < chunk / 512) ? + root->bytes / 512 - start : chunk / 512; + uint64_t offset = (uint64_t)physical[selected][i] * chunk / 512; + struct lwvm_extent *previous = root->extent_count ? &root->extents[root->extent_count - 1] : NULL; + if (previous && previous->physical_sector + previous->sectors == offset) + previous->sectors += size; + else root->extents[root->extent_count++] = (struct lwvm_extent){start, size, offset}; + } + return 0; +} diff --git a/boot/lwvm/lwvm.h b/boot/lwvm/lwvm.h new file mode 100644 index 00000000..20f195d2 --- /dev/null +++ b/boot/lwvm/lwvm.h @@ -0,0 +1,28 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#ifndef ORCHARD_LWVM_H +#define ORCHARD_LWVM_H +#include +#include + +#define LWVM_HEADER_BYTES 4096 +#define LWVM_CHUNKS 1024 +#define LWVM_PARTITIONS 8 + +struct lwvm_extent { + uint64_t start_sector, sectors, physical_sector; +}; +struct lwvm_root { + uint64_t media_bytes, chunk_bytes, bytes; + uint32_t header_crc32, partition_index; + uint8_t uuid[16]; + size_t extent_count; + struct lwvm_extent extents[LWVM_CHUNKS]; +}; + +int lwvm_uuid_parse(const char *text, uint8_t uuid[16]); +void lwvm_uuid_format(const uint8_t uuid[16], char text[37]); +int lwvm_geometry(uint64_t media, uint64_t *chunk, const char **error); +int lwvm_parse(const uint8_t primary[LWVM_HEADER_BYTES], + const uint8_t backup[LWVM_HEADER_BYTES], uint64_t media, + const uint8_t uuid[16], struct lwvm_root *root, const char **error); +#endif diff --git a/boot/lwvm/main.c b/boot/lwvm/main.c new file mode 100644 index 00000000..94b8b27b --- /dev/null +++ b/boot/lwvm/main.c @@ -0,0 +1,327 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +#define _GNU_SOURCE +#define _FILE_OFFSET_BITS 64 +#include "lwvm.h" +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __linux__ +#include +#include +#include +#include +#include +#endif + +static int error(const char *text) +{ + fprintf(stderr, "orchard-lwvm: %s\n", text); + return 1; +} +static int read_header(int fd, uint8_t *out, uint64_t offset) +{ + size_t done = 0; + while (done < LWVM_HEADER_BYTES) { + ssize_t n = pread(fd, out + done, LWVM_HEADER_BYTES - done, offset + done); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return -1; + done += n; + } + return 0; +} + +#ifdef __linux__ +#define DM_BUFFER (256 * 1024) +static int mapper_lock(void) +{ + struct stat st; + int fd = open("/run/orchard-lwvm.lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) return -1; + if (fstat(fd, &st) || !S_ISREG(st.st_mode) || st.st_uid != 0 || + st.st_nlink != 1 || flock(fd, LOCK_EX | LOCK_NB)) { + close(fd); + return -1; + } + return fd; +} +static void dm_init(struct dm_ioctl *d, const char *name) +{ + memset(d, 0, DM_BUFFER); + d->version[0] = 4; + d->data_size = DM_BUFFER; + d->data_start = sizeof(*d); + snprintf(d->name, sizeof(d->name), "%s", name); +} +static int dm_check(int fd, struct dm_ioctl *d, const char *name, + const char *uuid, const struct lwvm_root *root, + dev_t disk, int readonly, dev_t *device) +{ + char *cursor, *end; + size_t i; + dm_init(d, name); + if (ioctl(fd, DM_DEV_STATUS, d)) return -1; + if (strcmp(d->uuid, uuid) || !!(d->flags & DM_READONLY_FLAG) != readonly || + d->flags & DM_SUSPEND_FLAG) return -1; + *device = (dev_t)d->dev; + dm_init(d, name); + d->flags = DM_STATUS_TABLE_FLAG; + if (ioctl(fd, DM_TABLE_STATUS, d) || d->flags & DM_BUFFER_FULL_FLAG || + d->target_count != root->extent_count || d->data_start < sizeof(*d) || + d->data_start >= d->data_size || d->data_size > DM_BUFFER) return -1; + cursor = (char *)d + d->data_start; end = (char *)d + d->data_size; + for (i = 0; i < root->extent_count; i++) { + struct dm_target_spec *t; + unsigned int ma, mi; + uint64_t offset; + int consumed = 0; + char *params; + if ((size_t)(end - cursor) < sizeof(*t)) return -1; + t = (struct dm_target_spec *)cursor; + params = cursor + sizeof(*t); + if (!memchr(t->target_type, 0, sizeof(t->target_type)) || strcmp(t->target_type, "linear") || + !memchr(params, 0, end - params) || + sscanf(params, "%u:%u %" SCNu64 "%n", &ma, &mi, &offset, &consumed) != 3 || + params[consumed] || makedev(ma, mi) != disk || + t->sector_start != root->extents[i].start_sector || + t->length != root->extents[i].sectors || offset != root->extents[i].physical_sector) + return -1; + if (i + 1 < root->extent_count) { + /* Status next is relative to the first target, unlike table load. */ + char *next = (char *)d + d->data_start + t->next; + if (!t->next || next <= params || next > end || (uintptr_t)next % 8) return -1; + cursor = next; + } + } + return 0; +} +static int map_root(const struct lwvm_root *root, dev_t disk, int readonly, + char name[DM_NAME_LEN], char uuid[DM_UUID_LEN], char path[64]) +{ + struct dm_ioctl *d = calloc(1, DM_BUFFER); + char text[37], *cursor; + int control = -1, lock = -1, created = 0, rc = -1; + dev_t device = 0; + size_t i; + struct stat st; + if (!d) return -1; + lwvm_uuid_format(root->uuid, text); + snprintf(name, DM_NAME_LEN, "orchard-lwvm-%s", text); + snprintf(uuid, DM_UUID_LEN, "ORCHARD-LWVM-%s", text); + lock = mapper_lock(); + if (lock < 0) goto out; + control = open("/dev/mapper/control", O_RDWR | O_NOFOLLOW | O_CLOEXEC); + if (control < 0) goto out; + dm_init(d, name); + if (!ioctl(control, DM_DEV_STATUS, d)) { + if (dm_check(control, d, name, uuid, root, disk, readonly, &device)) goto out; + goto ready; + } + if (errno != ENXIO && errno != ENODEV) goto out; + dm_init(d, name); + snprintf(d->uuid, sizeof(d->uuid), "%s", uuid); + if (ioctl(control, DM_DEV_CREATE, d)) goto out; + created = 1; + dm_init(d, name); + d->target_count = root->extent_count; + d->flags = readonly ? DM_READONLY_FLAG : 0; + cursor = (char *)d + d->data_start; + for (i = 0; i < root->extent_count; i++) { + struct dm_target_spec *t = (struct dm_target_spec *)cursor; + size_t n; + t->sector_start = root->extents[i].start_sector; + t->length = root->extents[i].sectors; + strcpy(t->target_type, "linear"); + n = snprintf(cursor + sizeof(*t), 96, "%u:%u %" PRIu64, + major(disk), minor(disk), root->extents[i].physical_sector); + t->next = (sizeof(*t) + n + 1 + 7) & ~(size_t)7; + cursor += t->next; + if (cursor + sizeof(*t) + 96 > (char *)d + DM_BUFFER) goto out; + } + d->data_size = cursor - (char *)d; + if (ioctl(control, DM_TABLE_LOAD, d)) goto out; + dm_init(d, name); + if (ioctl(control, DM_DEV_SUSPEND, d) || + dm_check(control, d, name, uuid, root, disk, readonly, &device)) goto out; +ready: + snprintf(path, 64, "/dev/dm-%u", minor(device)); + if (stat(path, &st) && errno == ENOENT) { + if (mknod(path, S_IFBLK | 0600, device) && errno != EEXIST) goto out; + } + if (lstat(path, &st) || !S_ISBLK(st.st_mode) || st.st_rdev != device) goto out; + rc = 0; +out: + if (rc && created) { + dm_init(d, name); + ioctl(control, DM_DEV_REMOVE, d); + } + if (control >= 0) close(control); + if (lock >= 0) close(lock); + free(d); + return rc; +} + +static int unmap_root(const struct lwvm_root *root, dev_t disk, int readonly) +{ + struct dm_ioctl *d = calloc(1, DM_BUFFER); + char name[DM_NAME_LEN], uuid[DM_UUID_LEN], text[37]; + int control = -1, lock = -1, rc = -1; + dev_t device; + if (!d) return -1; + lwvm_uuid_format(root->uuid, text); + snprintf(name, sizeof(name), "orchard-lwvm-%s", text); + snprintf(uuid, sizeof(uuid), "ORCHARD-LWVM-%s", text); + lock = mapper_lock(); + if (lock < 0) goto out; + control = open("/dev/mapper/control", O_RDWR | O_NOFOLLOW | O_CLOEXEC); + if (control < 0) goto out; + dm_init(d, name); + if (ioctl(control, DM_DEV_STATUS, d)) { + if (errno == ENXIO || errno == ENODEV) rc = 0; + goto out; + } + if (dm_check(control, d, name, uuid, root, disk, readonly, &device)) goto out; + dm_init(d, name); + if (ioctl(control, DM_DEV_REMOVE, d)) goto out; + dm_init(d, name); + if (ioctl(control, DM_DEV_STATUS, d) && (errno == ENXIO || errno == ENODEV)) rc = 0; +out: + if (control >= 0) close(control); + if (lock >= 0) close(lock); + free(d); + return rc; +} +#endif + +static int decimal(const char **cursor, char end, uint64_t *out) +{ + const char *start = *cursor; + uint64_t value = 0; + while (**cursor >= '0' && **cursor <= '9') { + unsigned int digit = *(*cursor)++ - '0'; + if (value > (UINT64_MAX - digit) / 10) return -1; + value = value * 10 + digit; + } + if (*cursor == start || **cursor != end) return -1; + if (end) (*cursor)++; + *out = value; + return 0; +} + +static int command_unmap(int argc, char **argv) +{ + struct lwvm_root root = {0}; + struct stat st; + const char *disk = NULL, *requested = NULL, *mode = NULL; + uint64_t end = 0; + int i; + for (i = 2; i < argc; i++) { + const char *option = argv[i], *value; + if (i + 1 >= argc) return error("missing option value"); + value = argv[++i]; + if (!strcmp(option, "--disk") && !disk) disk = value; + else if (!strcmp(option, "--root") && !requested) requested = value; + else if (!strcmp(option, "--mode") && !mode) mode = value; + else if (!strcmp(option, "--extent") && root.extent_count < LWVM_CHUNKS) { + const char *cursor = value; + struct lwvm_extent *e = &root.extents[root.extent_count]; + if (decimal(&cursor, ':', &e->start_sector) || decimal(&cursor, ':', &e->sectors) || + decimal(&cursor, 0, &e->physical_sector) || !e->sectors || e->start_sector != end || + e->sectors > UINT64_MAX - end || e->sectors > UINT64_MAX - e->physical_sector) + return error("invalid expected extent"); + end += e->sectors; + root.extent_count++; + } else return error("unmap needs disk, root, mode and ordered expected extents"); + } + if (!disk || !requested || !mode || !root.extent_count || end > UINT64_MAX / 512 || + lwvm_uuid_parse(requested, root.uuid) || (strcmp(mode, "ro") && strcmp(mode, "rw"))) + return error("invalid expected mapping identity or mode"); + root.bytes = end * 512; + /* Cleanup must work after the provider's read budget expires. Metadata from + * stat supplies its identity; no provider is opened and no header is read. */ + if (lstat(disk, &st) || !S_ISBLK(st.st_mode)) return error("unmap requires the original block provider"); +#ifdef __linux__ + if (unmap_root(&root, st.st_rdev, !strcmp(mode, "ro"))) + return error("could not verify and remove the exact expected mapping"); + return 0; +#else + return error("unmap requires Linux device mapper"); +#endif +} + +int main(int argc, char **argv) +{ + uint8_t primary[LWVM_HEADER_BYTES], backup[LWVM_HEADER_BYTES], checked[LWVM_HEADER_BYTES], uuid[16]; + struct lwvm_root root; + struct stat st; + const char *disk = NULL, *requested = NULL, *mode = "ro", *why; + char name[128] = "", dm_uuid[129] = "", path[64] = "", formatted[37]; + uint64_t media, chunk; + int fd, i, json = 0, mapping, mode_seen = 0; + unsigned int disk_major = 0, disk_minor = 0; + if (argc >= 2 && !strcmp(argv[1], "unmap")) return command_unmap(argc, argv); + if (argc < 2 || (strcmp(argv[1], "inspect") && strcmp(argv[1], "map"))) + return error("usage: orchard-lwvm inspect|map --disk PATH --root UUID [--mode ro|rw] [--json]; unmap also requires --extent START:SECTORS:PHYSICAL for each expected extent"); + mapping = !strcmp(argv[1], "map"); + for (i = 2; i < argc; i++) { + const char *option = argv[i], *value; + if (!strcmp(option, "--json")) { json = 1; continue; } + if (i + 1 >= argc) return error("missing option value"); + value = argv[++i]; + if (!strcmp(option, "--disk") && !disk) disk = value; + else if (!strcmp(option, "--root") && !requested) requested = value; + else if (!strcmp(option, "--mode") && !mode_seen) { mode = value; mode_seen = 1; } + else return error("invalid or repeated argument"); + } + if (!disk || !requested || lwvm_uuid_parse(requested, uuid) || + (strcmp(mode, "ro") && strcmp(mode, "rw"))) return error("invalid disk, root UUID or mode"); + fd = open(disk, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0 || fstat(fd, &st)) return error("could not open disk"); + if (S_ISREG(st.st_mode) && !mapping) media = st.st_size; +#ifdef __linux__ + else if (S_ISBLK(st.st_mode)) { + int ro; + if (ioctl(fd, BLKGETSIZE64, &media) || ioctl(fd, BLKROGET, &ro) || + (mapping && !strcmp(mode, "rw") && ro)) return error("disk length or writable mode is unavailable"); + disk_major = major(st.st_rdev); disk_minor = minor(st.st_rdev); + } +#endif + else return error("inspect requires an image or block device; map requires a block device"); + if (lwvm_geometry(media, &chunk, &why)) return error(why); + if (read_header(fd, primary, 0) || read_header(fd, backup, chunk - LWVM_HEADER_BYTES)) + return error("could not read both LwVM headers"); + if (read_header(fd, checked, 0) || memcmp(primary, checked, sizeof(primary))) + return error("LwVM primary changed while reading the header pair"); + if (lwvm_parse(primary, backup, media, uuid, &root, &why)) return error(why); + if (mapping) { +#ifdef __linux__ + if (map_root(&root, st.st_rdev, !strcmp(mode, "ro"), name, dm_uuid, path)) + return error("could not create or verify the exact DM-linear mapping"); +#else + return error("mapping requires Linux device mapper"); +#endif + } + close(fd); + if (mapping && !json) { puts(path); return 0; } + lwvm_uuid_format(root.uuid, formatted); + printf("{\"schema\":1,\"kind\":\"orchard-lwvm-root\",\"root_uuid\":\"%s\"," + "\"partition_index\":%u,\"header_crc32\":\"%08x\",\"media_bytes\":%" PRIu64 + ",\"chunk_bytes\":%" PRIu64 ",\"bytes\":%" PRIu64 ",\"disk\":{\"major\":", + formatted, root.partition_index, root.header_crc32, root.media_bytes, root.chunk_bytes, root.bytes); + if (S_ISREG(st.st_mode)) printf("null,\"minor\":null}"); + else printf("%u,\"minor\":%u}", disk_major, disk_minor); + printf(",\"extents\":["); + for (i = 0; i < (int)root.extent_count; i++) { + const struct lwvm_extent *e = &root.extents[i]; + printf("%s{\"start_sector\":%" PRIu64 ",\"sectors\":%" PRIu64 ",\"physical_sector\":%" PRIu64 "}", + i ? "," : "", e->start_sector, e->sectors, e->physical_sector); + } + printf("]"); + if (mapping) printf(",\"mapping\":{\"name\":\"%s\",\"uuid\":\"%s\",\"device\":\"%s\",\"mode\":\"%s\"}", name, dm_uuid, path, mode); + puts("}"); + return 0; +} diff --git a/chips/s5l8942x.toml b/chips/s5l8942x.toml index 50ba0c2d..da8134d0 100644 --- a/chips/s5l8942x.toml +++ b/chips/s5l8942x.toml @@ -14,3 +14,10 @@ stages = ["iBSS", "iBEC"] platform = "P105 on a Cascadia-based build" stages = "Project Cascadia P105 boot chain" reference_version = "Project Cascadia builds its A5 boot chain from 8.4.1 / 12H321" +nand_reads = "Measured P105 64 GB PPN geometry has eight CAUs per bus, 2088 blocks per CAU and 128 normal pages per block; exact ARMv7 read-topic qualification is pending (docs/bring-up/p105-nand-reads.md)." +nand_program = "The P105 complete-page PPN program candidate defaults off, verifies blank destination and full readback, and exposes no erase operation; exact-revision hardware qualification is pending (docs/bring-up/p105-nand-program.md)." +nand_translation = "The P105 VFL/SwissPPNFTL reader reconstructs the measured 64 GB native map from saved pages and has finite request/time bounds; exact-revision hardware qualification is pending (docs/bring-up/p105-sftl-reader.md)." +cascadia_profile = "devices/p105/cascadia.toml; external-initramfs handoff awaits hardware qualification" + +# Public Cascadia RAM recovery correlates a guarded host USB identity with the live FDT and boot ID. +# Linux SoC ECID access and on-device public recovery identity remain unqualified. diff --git a/devices/p105/README.md b/devices/p105/README.md index a39986c9..f66904bd 100644 --- a/devices/p105/README.md +++ b/devices/p105/README.md @@ -26,3 +26,14 @@ the IPSW members `058-24036-023.dmg`, `kernelcache.release.p105`, `Firmware/dfu/ The touch calibration is per unit and private: `lib/firmware/mtcal.bin`, the unit's syscfg `MtCl`, captured from its jailbroken iOS. It is never part of a public recipe or release. + +## ARMv7 recovery assembly + +The platform's `cascadia` payload producer builds the staging loader from +source with `cascadia.toml`. Select this board explicitly when resolving a +release's inputs: `python -m orchard.inputs --device p105 --output inputs.json`. +The profile identifies the memory window and handoff registers for this board. +The generated loader checks its configuration before copying the recovery +initramfs into the reserved high-memory range. The DTB names that initramfs. +This extension needs a hardware boot of the final producer revision; the +earlier installed P105 boot used an initramfs compiled into its kernel. 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/devices/p105/device.toml b/devices/p105/device.toml index f87d1027..ff617f52 100644 --- a/devices/p105/device.toml +++ b/devices/p105/device.toml @@ -13,6 +13,8 @@ chip = 0x8942 board = 0x0a [boot] +# Public recovery uses a host-bound USB identity handoff; hardware qualification is pending. +# Source-built Cascadia payload profile: cascadia.toml. External-initramfs handoff awaits testing. methods = ["jailbreak-kdfu", "recovery-autogo"] entries = ["pico-checkm8", "kdfu"] # The CLI consumes an owner-private boot manifest. Public launch assets await release integration. @@ -38,7 +40,13 @@ accelerometer = "missing" gyroscope = "missing" light_sensor = "missing" touch_id = "missing" +# PPN geometry/read topic and native-map provider await exact-build qualification. +# See docs/bring-up/p105-nand-reads.md. +# The bounded native VFL/SFTL reader awaits an exact-revision device boot. +# See docs/bring-up/p105-sftl-reader.md; logical storage capabilities stay missing. storage_read = "missing" +# The default-off complete-page primitive does not provide a qualified logical writer. +# See docs/bring-up/p105-nand-program.md for its checks and pending hardware work. storage_write = "missing" wifi = "untested" bluetooth_input = "untested" diff --git a/docs/boot-contract.md b/docs/boot-contract.md index aa7c72fc..ea0c1527 100644 --- a/docs/boot-contract.md +++ b/docs/boot-contract.md @@ -33,11 +33,53 @@ distribution needs `kernel-install`, mkinitcpio or dracut, and every init system works the same way. orchard's initrd (`boot/initramfs/root`) holds BusyBox and the platform's storage -modules only. It loads them, waits up to 30 s for the partition that +modules. It loads them, waits up to 30 s for the partition that `root=PARTUUID=` names, mounts it (`rootfstype=`, `rootflags=`, `ro`/`rw` as given) and starts its `/sbin/init` (or `init=`). If any step fails, it says which one and opens a shell on the console. +Platforms with `[root] format = "lwvm"` also carry the static +[`orchard-lwvm` mapper](../boot/lwvm/README.md) and owner SSH tools. They look +for `root=PARTUUID=` in the current checked LwVM header pair on a kernel +`/dev/applesftlN` logical provider. The helper maps only a Linux filesystem +partition with zero attributes, preserving its ordered physical extents. +There is no Python or NBD worker in this installed-root path. A damaged pair +leaves the RAM console, USB serial and key-provisioned SSH available. +`recovery-authorize-key` accepts an owner's Ed25519 public key over physical +serial; `recovery-start-ssh` shows the ephemeral host key for pinning and starts +key-only SSH at `172.16.42.1`. These credentials remain in RAM. After mounting, +PID1 moves `/dev`, `/proc`, `/sys` and `/run` into the root and starts its init. +On systemd roots, a service resumes physical serial rescue from static tools +on `/run`; the installed system starts its normal SSH service. + +The mapper supplies a volume view, while the kernel backend controls flash +writes and read limits. An installed-root entry needs an explicitly reviewed +backend lifetime and write policy. The diagnostic defaults of 32768 page +reads and 600000 ms from probe cannot sustain an installed desktop session. +`rw` alone never enables the default-off SFTL writer. Device installation +remains gated on qualification of the exact kernel, policy and initrd. + +The selected P105 initrd loads `apple-sftl` as a module. Its loader validates +`apple_sftl.write_enable` as `0` or `1`; `apple_sftl.max_page_reads` and +`apple_sftl.max_io_time_ms` accept canonical decimal integers from 1 through +4294967295. A write opt-in requires both explicit limits. Repeated, unknown +or malformed policy parameters stop module loading and leave RAM recovery. +The limits bound the whole module lifetime, including startup scans. + +An operator starts with a read-only entry and substitutes reviewed integers +for the placeholders before building the boot image: + +``` +root=PARTUUID= ro apple_sftl.write_enable=0 apple_sftl.max_page_reads= apple_sftl.max_io_time_ms= +``` + +After qualifying the kernel's write capabilities and the operating limits, +the corresponding writable entry uses `rw` and +`apple_sftl.write_enable=1` with both explicit limits. This document supplies +no qualified operating values. The kernel needs `CONFIG_MTD_APPLE_SFTL=m`, +`CONFIG_MD=y` and `CONFIG_BLK_DEV_DM=y`; the linear target is included in +device mapper. Config export and physical qualification gate this path. + ## Partitions (GPT) | Partition | Type GUID | Filesystem | Count | @@ -199,3 +241,50 @@ the same m1n1 code, splash included. - Every change must be tested on both ways into m1n1: Pongo, and iBoot (`launcher-kit`, `./launch boot --bare`, then the client over the proxy). Device time goes through the user. Which SoCs have an m1n1 storage backend is in [bring-up/bootloader/experiments.md](bring-up/bootloader/experiments.md#per-device-menu). + +## Host-bound ARMv7 recovery identity + +Cascadia recovery templates carry a descriptor-selected identity policy in the +standard `/chosen/bootargs` string. These are userland parameters, not hardware +register descriptions. `orchard.recovery-policy=VALUE` and +`orchard.recovery-handoff=VALUE` encode schema-1 JSON objects as canonical, +unpadded base64url. Each token is bounded; the entire ARM command line remains +at most 1,023 ASCII bytes. Duplicate JSON keys, repeated parameters, unknown +fields, invalid types and incomplete records fail before recovery USB binds. + +The policy names `source = "host-usb"`, canonical `profile`, accepted live FDT +`compatibles`, and integer `chip` and `board` identifiers. The public runtime +contains the same policy, generated from the selected hardware descriptor. +A template contains the policy alone. The guarded transport verifies its +original digest and freshly queries the selected iRecovery endpoint for its +ECID, CPID, BDID and product. It then binds one nonzero random 128-bit +`handoff_id` to that observation and the original `template_sha256`. The handoff +also contains `schema`, `source`, `profile`, `ecid`, `chip` and `board`. The +transport rebuilds the checked CB32 configuration and FDT while retaining the +kernel and initramfs bytes, then rechecks the selected endpoint immediately +before its single upload. Its private attempt receipt records both image +hashes and the nonce. + +The runtime requires the live compatible, policy token and staged policy to +agree. It publishes `identity_source = "host-usb"`, the nonce and original +image digest with the kernel's fresh boot ID. This records a host observation; +it does not claim that Linux read a SoC ECID register. USB serial includes +`HND:` followed by the nonce, alongside the canonical board, ECID and boot ID. +The host requires that nonce before accepting this boot, then checks the full +provenance again through serial SSH bootstrap and authenticated status. A +later command may resume only through the matching private completed attempt +receipt, including the same profile, ECID, nonce, boot ID and template digest. +An unknown or changed handoff stops before a storage operation. + +The existing ARM64 path requires its live ADT MTD identity source. Absence of a +host-bound policy preserves that path; an invalid host-bound policy or missing +handoff never falls back to ADT. This handoff currently serves the guarded +host RAM-boot path. On-device startup needs its own checked identity source +before it can use this public recovery runtime. + +The platform recovery policy also selects USB function dependencies and early +storage activation. ARMv7 loads its declared ECM/ACM modules, keeps serial +available through network setup, and leaves SFTL unloaded until a manual +request after serial and authenticated SSH are ready. The module's finite +read policy bounds that later request. Hardware feature states change only +after the exact submitted stack passes its physical checks. diff --git a/docs/bring-up/p105-nand-program.md b/docs/bring-up/p105-nand-program.md new file mode 100644 index 00000000..e468562d --- /dev/null +++ b/docs/bring-up/p105-nand-program.md @@ -0,0 +1,48 @@ +# P105 complete-page PPN programming + +The proposed H2FMI program primitive accepts one complete physical page: 16 KiB +of data and 64 metadata bytes. `CONFIG_MTD_APPLE_H2FMI_WRITE` defaults to `n`. +Enabling it publishes raw MTD programming through the measured PPN command +path. Read-only qualification builds leave the option disabled. + +The caller supplies every data and metadata byte. The driver serializes the +transaction with the bus lock, checks the destination's erased status and +every raw byte, then programs the page and validates completion. Success +requires a fresh read with good status and an exact comparison of the whole +page. MTD reports progress only after that readback succeeds. An uncertain +completion or mismatched readback stops further I/O on the bus. + +The request must align to a physical page and include a full data/OOB pair. +Block zero and the two reserved package-tail blocks are excluded. This +primitive exposes no erase callback and owns no free-space map. A dependent +VFL/SFTL writer must decide which blank pages it can allocate while preserving +Apple's native mapping records. + +The driver binds the shared `apple,s5l8940x-fmi` compatible, which P105 uses +in this source tree. Its imported two-CAU parameter profile also needs a +regression check before merge; the measured 64 GB profile does not qualify +that hardware. + +## Source and evidence + +The candidate is +[`cef7165b078f1d52c36fd9a38467e999ab2fefdd`](https://github.com/shaynelegault/linux/commit/cef7165b078f1d52c36fd9a38467e999ab2fefdd), +on the geometry/read topic +`fe8c2f6960bd9d9fa0b051560598069497b31d82`. The program fixture exercised +the actual driver functions with fake MMIO and passed 134,181 checks under +ASan/UBSan. Cases cover blank detection, request bounds, completion statuses, +transport failures and full readback mismatches. The read fixture retained +its 140,146 checks. Strict checkpatch reported no errors or warnings. + +Focused ARMv7 object builds passed with programming enabled and disabled; +the KUnit fixture also compiled. Those builds used the existing qualified +ARMv7 generated headers. Their source hashes match this candidate, but they +were object checks rather than a complete current-base kernel build or a +kernel KUnit run. + +Earlier bounded device programming supplied protocol evidence. This exact +revision still needs an approved physical test, including the failure and +recovery paths. It has no new physical-programming result. Raw page readback +does not establish logical-map persistence after restart or recovery after +interrupted writes. P105 `storage_write` remains `missing` until the logical +backend earns that capability through device qualification. diff --git a/docs/bring-up/p105-nand-reads.md b/docs/bring-up/p105-nand-reads.md new file mode 100644 index 00000000..528bf571 --- /dev/null +++ b/docs/bring-up/p105-nand-reads.md @@ -0,0 +1,42 @@ +# P105 PPN geometry and reads + +The measured 64 GB P105 package exposes two H2FMI buses. Each bus has eight +CAUs with 2,088 blocks per CAU and 128 normal-view pages per block. A page +contains 16 KiB of data and 64 bytes of metadata. The reference two-CAU +package uses a different address layout, so the driver queries both PPN +parameter copies and accepts only the measured profiles. + +The 64 GB row places the page in bits 0 through 6, the block in bits 7 +through 18 and the CAU from bit 19. Four row cycles carry the address. +Context reads use the separately selected SLC view. The MTD byte offset +remains 64 bits throughout, including addresses above 4 GiB. + +The proposed read path validates completion status before asking for payload, +restores inherited controller state on exit, and stops a bus after a +transport or cleanup failure. A failed zero-byte read returns a terminal +error, which prevents MEMREAD from repeating the same page indefinitely. +Page programming belongs to a separate topic. + +## Evidence and scope + +The source branch starts at current ARMv7 base +`97d6283e22e8f989a9d1027d99dc519b5f1f7f81` and ends at +[`fe8c2f6960bd9d9fa0b051560598069497b31d82`](https://github.com/shaynelegault/linux/commit/fe8c2f6960bd9d9fa0b051560598069497b31d82). +The actual read functions passed 140,146 assertions under ASan and UBSan. +Focused ARMv7 driver and KUnit objects compiled. Synthetic public KUnit +fixtures check both parameter profiles, address uniqueness, context-view +bounds and malformed responses. + +Earlier device captures supplied the geometry and native logical-page +crosschecks. Exact-revision hardware qualification remains pending. The new +candidate must establish USB recovery before a bounded read-only session; +its source omits the program and erase paths. + +Raw PPN reads expose physical flash pages. Apple's VFL and SwissPPNFTL maps +still determine the logical medium, with LwVM describing its partitions. +The dependent kernel translation reader follows those native maps. +Encrypted iOS volume contents retain their encryption at this layer. + +P105 storage capability states await qualification of the logical provider. +The read topic also changes the imported reference profile's shared +controller code; that profile needs a regression check before merge. diff --git a/docs/bring-up/p105-sftl-reader.md b/docs/bring-up/p105-sftl-reader.md new file mode 100644 index 00000000..cf8bddc1 --- /dev/null +++ b/docs/bring-up/p105-sftl-reader.md @@ -0,0 +1,76 @@ +# P105 native VFL and SwissPPNFTL reader + +H2FMI exposes physical PPN pages. Apple's VFL and SwissPPNFTL records supply +the logical medium above them. The proposed `apple-sftl` kernel driver reads +those records and publishes a read-only `applesftlN` disk. LwVM partition +mapping belongs above that disk, where encrypted iOS contents retain their +encryption. + +The measured profile has two buses and eight CAUs per bus, each with 2,088 +blocks of 128 normal-view pages. H2FMI supplies each 16 KiB data page with its +64 metadata bytes through a canonical provider. Context reads select the +separate SLC address view. This provider offers no program or erase callback. + +Recovery requires agreeing VFL context replicas and lane tables. It validates +the saved SFTL context fragments, then replays later data and trim records in +sequence order. Completed-block summaries preserve allocation floors; open +blocks are scanned fully so an erased lane cannot hide a later completed +stripe. Malformed or incomplete maps prevent disk registration. Unsupported +geometry returns an error. + +The map contains 15,625,016 entries, including sixteen internal keys. The +exported medium contains 15,625,000 logical 4 KiB pages, or 64,000,000,000 bytes, +and accepts 512-byte sector reads. The frontend serializes access to its +preallocated request buffer and drains requests before releasing the provider. + +## Bounded qualification + +The latest reader candidate is +[`e7d118292d828b9796d85d19872653f7d4a8cb67`](https://github.com/shaynelegault/linux/commit/e7d118292d828b9796d85d19872653f7d4a8cb67). +It follows the canonical H2FMI provider and native-map reader commits +`9b494217444fc61adae2f95bc2ec05e461d45049` and +`e5960c537455b9c40c57deb53823e80ca9b13bb4`. + +`max_page_reads` defaults to 32,768 physical page requests across recovery and +subsequent block reads. `max_io_time_ms` defaults to 600,000 milliseconds from +probe. Both read-only module parameters must be positive. The driver checks +the deadline before and after each provider transaction. One already-bounded +transaction limits the overrun. Exhausting either policy or receiving a +provider error permanently stops that reader instance, including later reads +that require no physical I/O. These limits define a qualification session; +a mounted root cannot depend on the reader beyond its lifetime. + +## Software checks and limits + +The unchanged mapping core reconstructed a saved private capture through +sequence 187831212. An independent Python reference agreed at all 28,830 +checked endpoints of the map union. Both LwVM headers and the System prefix +matched previously captured native iOS logical reads. This comparison used +saved pages and performed zero device operations. Captured pages remain +private; public fixtures generate synthetic media. + +At `e5960c537`, eight tests passed in an ARM64 QEMU KUnit kernel built from +pristine pinned Linux v6.12 plus the exact reader/test sources and required +Kbuild entries. This ran the mapping code without Cascadia or an H2FMI +hardware backend. The host version of those fixtures also passed under +ASan/UBSan with allocation, mutex and tree-API shims. + +The canonical H2FMI provider shares controller code with the imported +two-CAU profile. That profile needs a regression check; native-map recovery +currently accepts only the measured 64 GB geometry. + +The lifetime revision adds five boundary tests for page budgets, deadlines, +provider errors and invalid policies. Its actual core/test fixture passed +again under ASan/UBSan. The changed frontend and test objects compiled against +the current ARMv7 generated-header build with `W=1` and warnings-as-errors, +retaining the existing `vmstat.h` enum-arithmetic warning as a warning. The +earlier core, frontend, fixture and provider object targets also compiled. +These checks establish source behavior and compilation; the new lifetime +tests have no separate kernel KUnit run recorded here. + +The exact candidate still needs a complete qualification image and an +approved P105 boot with working USB recovery, followed by bounded native-map +reads. Host reconstruction and an ARM64 KUnit kernel do not qualify ARMv7 +controller timing. P105 `storage_read` and `storage_write` remain `missing` +while this native logical provider awaits device qualification. Sustained +writes and interruption recovery belong to the dependent writer work. diff --git a/docs/distribution-contract.md b/docs/distribution-contract.md index 082f604f..0d3bb439 100644 --- a/docs/distribution-contract.md +++ b/docs/distribution-contract.md @@ -21,11 +21,11 @@ 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.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.inputs --output FILE [--platform P …] [--device BOARD] [--platform-loader P=FILE] [--platform-tools P=DIR] [--platform-lwvm P=FILE] [--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.image.platform P --output DIR --kernel-dir BUNDLE --busybox FILE [--receipt FILE] [--root-architecture arm64|arm] [--device BOARD] [--lwvm FILE] [--rescue-tools DIR]` | 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.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.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] [--platform P] [--device BOARD]` | 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` | @@ -34,3 +34,60 @@ without orchard takes it as an argument. Changing one of these is a contract change: say so in the changelog, and update both distributions in the same push set. + +## Architecture and board selection + +The default input document still selects every ARM64 platform and preserves +its `shared` m1n1/Pongo/tool record. An explicit ARMv7 build selects a board +with `orchard.inputs --device p105`. Each selected platform record includes +`architecture` (`arm64` or `arm`), `image`, `loader` (`kind` and `file`) and +its own `tools` paths. `boards` contains the selected board when `--device` +is given. A document has one root architecture; mixing ARM32 and ARM64 +platforms is refused before producing components. The top-level +`architecture` repeats that target for the distribution's root builder. + +`--platform-loader PLATFORM=FILE` and `--platform-tools PLATFORM=DIR` supply +finished components for offline builds. ARMv7 uses these per-platform inputs +and has an empty `shared` record. A Cascadia payload uses one board's handoff +profile; a platform with several boards requires explicit board selection. + +`orchard.image.platform --root-architecture arm --device p105` checks the +root ISA and writes a layer for that board. Its kernel image comes from the +platform descriptor. Optional `[root] storage_modules` in that descriptor +names the early-root modules; an empty list requires built-in storage. +For a platform with `root.devices`, those modules apply only to the selected +profiles; other boards retain an empty early-root module set. + +An LwVM platform sets `[root] format = "lwvm"`. Optional `root.devices` +restricts that installed-root path to the named device profiles; P105's +backend therefore does not apply to J1 through their shared kernel platform. +Platform-layer builds with different selected root backends require `--device`. +Its schema-1 input record +adds `platforms[P].tools.lwvm`, the absolute path of the static mapper for +the selected architecture. `--platform-lwvm P=FILE` supplies that finished +component for an offline build. The existing six tool paths and `shared` +record retain their meaning. Consumers forward this path as `--lwvm FILE` +to the recovery and platform producers. The platform producer also accepts +`--rescue-tools DIR` for the matching static Dropbear tools; otherwise it +resolves those tools through Orchard's cache. LwVM installed roots require +built-in `CONFIG_MD=y`, `CONFIG_BLK_DEV_DM=y` and its linear target alongside the logical +storage backend. P105 remains `install = false` until the exact path passes +physical qualification. +Every platform record also identifies `root_format` as `gpt` or `lwvm`. +Consumers require `tools.lwvm` for `lwvm` before building a root, and may +default an older snapshot's absent `root_format` to `gpt`. + +`orchard.image.recovery --platform apple-armv7 --device p105` stages an +ARM EABI5 hard-float runtime and invokes the source-built Cascadia loader. +The ELF dependency reader follows ELF32 libraries as well as ELF64, and +recovery refuses libraries or executables from another ISA. Cascadia puts +the external recovery initramfs in a reserved RAM range and passes its +addresses through the DTB. Its image receipt has +`initramfs_encoding = "cascadia-dtb"` and `architecture = "arm"`; the component +records describe contiguous `loader`, `kernel`, `dtb`, and `initramfs` ranges +with padding records where needed. This new handoff awaits physical testing. + +A distribution that accepts a board also declares `selects-device = true` +under `[release]` in `orchard.toml`, alongside `selects-platform = true`. +Orchard then forwards its selected `--device` to that distribution's build +entry. Existing distribution entries retain their previous arguments. diff --git a/kernel/README.md b/kernel/README.md index e9a8addc..42a803be 100644 --- a/kernel/README.md +++ b/kernel/README.md @@ -9,6 +9,17 @@ the platform. A device name resolves to its platform through `DIRECTORY/.dtb`. Git and Podman are required. `series.py --platform NAME` prints the checked descriptor of a platform or device. +`series/` lists patch directories in application order. Each +directory contributes patches recursively in filename order. A patch can +have an adjacent `.patch.toml` with `schema = 1` and `platforms = ["name"]` to +restrict where it applies. Plain directory entries select unscoped patches +and scopes matching the platform. A `scoped patches/drivers/` +entry selects only matching scopes, so platforms on different kernel bases +can use the same subsystem layout. The selector rejects unknown platforms, +malformed or orphaned scopes, paths outside the kernel directory and duplicate +patch selection. See [Contributing](../CONTRIBUTING.md#where-kernel-patches-go) +and [kernel-tree](../tools/kernel-tree/README.md) for export and regeneration. + `orchard build` runs it; build with `orchard build --device j71b` (platform `apple-16k` plus the j71b DTB) or `orchard build --platform apple-16k`, not with `kernel/build` directly. It holds the shared tree's lock and switches the diff --git a/kernel/build b/kernel/build index 814bee7c..bf46308a 100755 --- a/kernel/build +++ b/kernel/build @@ -42,9 +42,12 @@ if [[ -n $tree ]]; then fi config="$here/../$(field config)" lock="$here/../$(field upstream)" patches=() -while IFS= read -r patch; do - patches+=("$patch") -done < <("$python" "$here/series.py" "$device") +selected_patches=$("$python" "$here/series.py" "$device") +if [[ -n $selected_patches ]]; then + while IFS= read -r patch; do + patches+=("$patch") + done <<< "$selected_patches" +fi [[ -f "$config" ]] || { echo "Missing kernel config for platform $platform" >&2; exit 1; } command -v git >/dev/null || { echo 'Git is required' >&2; exit 1; } if [[ ${ORCHARD_BUILD_NATIVE:-0} != 1 ]]; then diff --git a/kernel/configs/apple-armv7/config b/kernel/configs/apple-armv7/config index 6a2ab9d0..860894db 100644 --- a/kernel/configs/apple-armv7/config +++ b/kernel/configs/apple-armv7/config @@ -765,6 +765,8 @@ CONFIG_CMDLINE_PARTITION=y CONFIG_BLK_MQ_PCI=y CONFIG_BLK_MQ_VIRTIO=y CONFIG_BLK_PM=y +CONFIG_BLOCK_HOLDER_DEPRECATED=y +CONFIG_BLK_MQ_STACKING=y # # IO Schedulers @@ -1450,6 +1452,7 @@ CONFIG_MTD_OF_PARTS=y # CONFIG_MTD_OOPS is not set # CONFIG_MTD_SWAP is not set # CONFIG_MTD_PARTITIONED_MASTER is not set +CONFIG_MTD_APPLE_SFTL=m # # RAM/ROM/Flash chip drivers @@ -1505,6 +1508,7 @@ CONFIG_MTD_SPEAR_SMI=y # # CONFIG_MTD_DOCG3 is not set CONFIG_MTD_APPLE_H2FMI=y +# CONFIG_MTD_APPLE_H2FMI_WRITE is not set # end of Self-contained MTD device drivers # @@ -1703,7 +1707,34 @@ CONFIG_SCSI_MOD=y # end of SCSI device support # CONFIG_ATA is not set -# CONFIG_MD is not set +CONFIG_MD=y +# CONFIG_BLK_DEV_MD is not set +CONFIG_MD_BITMAP_FILE=y +# CONFIG_BCACHE is not set +CONFIG_BLK_DEV_DM_BUILTIN=y +CONFIG_BLK_DEV_DM=y +# CONFIG_DM_DEBUG is not set +# CONFIG_DM_UNSTRIPED is not set +# CONFIG_DM_CRYPT is not set +# CONFIG_DM_SNAPSHOT is not set +# CONFIG_DM_THIN_PROVISIONING is not set +# CONFIG_DM_CACHE is not set +# CONFIG_DM_WRITECACHE is not set +# CONFIG_DM_ERA is not set +# CONFIG_DM_CLONE is not set +# CONFIG_DM_MIRROR is not set +# CONFIG_DM_RAID is not set +# CONFIG_DM_ZERO is not set +# CONFIG_DM_MULTIPATH is not set +# CONFIG_DM_DELAY is not set +# CONFIG_DM_DUST is not set +# CONFIG_DM_INIT is not set +# CONFIG_DM_UEVENT is not set +# CONFIG_DM_FLAKEY is not set +# CONFIG_DM_VERITY is not set +# CONFIG_DM_SWITCH is not set +# CONFIG_DM_LOG_WRITES is not set +# CONFIG_DM_INTEGRITY is not set # CONFIG_TARGET_CORE is not set # CONFIG_FUSION is not set diff --git a/kernel/patches/drivers/mtd/0001-mtd-apple-h2fmi-Discover-and-validate-PPN-package-ge.patch b/kernel/patches/drivers/mtd/0001-mtd-apple-h2fmi-Discover-and-validate-PPN-package-ge.patch new file mode 100644 index 00000000..3747151b --- /dev/null +++ b/kernel/patches/drivers/mtd/0001-mtd-apple-h2fmi-Discover-and-validate-PPN-package-ge.patch @@ -0,0 +1,824 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> +Date: Fri, 6 Nov 2026 11:29:40 -0600 +Subject: [PATCH] mtd: apple-h2fmi: Discover and validate PPN package geometry + +Query both PPN parameter pages before registering MTD devices. The measured +64 GB P105 has eight CAUs per bus with 128 pages per block; the imported +fixed geometry aliases addresses on that profile. Retain support for the +reference two-CAU package and reject unsupported or mismatched profiles. + +Use four row address cycles for the supported PPN protocol. Check status +before requesting payload, restore the inherited controller state on every +exit, and stop I/O after a transport or cleanup failure. Report a terminal +error for a failed zero-byte MTD read so MEMREAD cannot repeat that page. +Keep page programming and capture interfaces outside this read change. + +Add synthetic KUnit tests for parameter parsing and address uniqueness, +including offsets beyond 4 GiB and the context address view. A host harness +of the actual read functions passed 140146 ASan/UBSan assertions. Exact +current-base hardware qualification is pending. + +Assisted-by: LLM +--- + drivers/mtd/devices/Kconfig | 11 +- + drivers/mtd/devices/Makefile | 2 + + drivers/mtd/devices/mtd-apple-h2fmi-test.c | 172 ++++++++++++ + drivers/mtd/devices/mtd-apple-h2fmi.c | 251 ++++++++++-------- + drivers/mtd/devices/mtd-apple-h2fmi.h | 122 +++++++++ + .../testing/kunit/configs/apple_h2fmi.config | 3 + + 6 files changed, 445 insertions(+), 116 deletions(-) + create mode 100644 drivers/mtd/devices/mtd-apple-h2fmi-test.c + create mode 100644 drivers/mtd/devices/mtd-apple-h2fmi.h + create mode 100644 tools/testing/kunit/configs/apple_h2fmi.config + +diff --git a/drivers/mtd/devices/Kconfig b/drivers/mtd/devices/Kconfig +index e73b65842..2b6e1efae 100644 +--- a/drivers/mtd/devices/Kconfig ++++ b/drivers/mtd/devices/Kconfig +@@ -213,7 +213,7 @@ config MTD_APPLE_H2FMI + Apple's flash controller as it appears on the A5 (S5L8940X/8942X), + driving the Hynix PPN packages the iPad mini 1 boots iOS from. The + package does its own ECC, so this is a plain MTD and not a raw-NAND +- driver. One MTD per die; four on this board. ++ driver. One MTD per CAU; geometry is read from each package. + + READ ONLY. The driver's opcode allowlist contains no program and no + erase, so it cannot damage whatever the NAND already holds -- which +@@ -224,6 +224,15 @@ config MTD_APPLE_H2FMI + + If unsure, say N. + ++config MTD_APPLE_H2FMI_KUNIT_TEST ++ tristate "KUnit tests for Apple H2FMI PPN geometry" if !KUNIT_ALL_TESTS ++ depends on KUNIT ++ default KUNIT_ALL_TESTS ++ help ++ Test PPN parameter parsing, row encoding and page-status handling. ++ Both measured PPN package profiles are covered. ++ These tests use synthetic parameter pages and do not access hardware. ++ + if MTD_DOCG3 + config BCH_CONST_M + default 14 +diff --git a/drivers/mtd/devices/Makefile b/drivers/mtd/devices/Makefile +index 8135496fe..cc0a852d2 100644 +--- a/drivers/mtd/devices/Makefile ++++ b/drivers/mtd/devices/Makefile +@@ -22,3 +22,5 @@ obj-$(CONFIG_MTD_APPLE_H2FMI) += mtd-apple-h2fmi.o + + + CFLAGS_docg3.o += -I$(src) ++ ++obj-$(CONFIG_MTD_APPLE_H2FMI_KUNIT_TEST) += mtd-apple-h2fmi-test.o +diff --git a/drivers/mtd/devices/mtd-apple-h2fmi-test.c b/drivers/mtd/devices/mtd-apple-h2fmi-test.c +new file mode 100644 +index 000000000..5308972e7 +--- /dev/null ++++ b/drivers/mtd/devices/mtd-apple-h2fmi-test.c +@@ -0,0 +1,172 @@ ++// SPDX-License-Identifier: GPL-2.0-only ++#include ++#include ++ ++#include "mtd-apple-h2fmi.h" ++ ++static const u32 ppn_16gb[] = { ++ 2, 4, 1064, 11, 256, 128, 8, 1, 2, 16448, ++}; ++ ++static const u32 ppn_64gb[] = { ++ 8, 3, 2088, 12, 128, 64, 7, 1, 2, 16448, ++}; ++ ++static void h2fmi_test_params(u8 *params, const u32 *fields) ++{ ++ int i; ++ ++ memset(params, 0, H2FMI_PARAMS_SIZE); ++ memcpy(params, "PPN Device Info", sizeof("PPN Device Info") - 1); ++ for (i = 0; i < ARRAY_SIZE(ppn_64gb); i++) ++ put_unaligned_le32(fields[i], params + 0x10 + i * sizeof(u32)); ++} ++ ++static void h2fmi_geometry_test(struct kunit *test) ++{ ++ u8 params[H2FMI_PARAMS_SIZE]; ++ struct h2fmi_geometry g; ++ ++ h2fmi_test_params(params, ppn_16gb); ++ KUNIT_ASSERT_EQ(test, h2fmi_parse_geometry(params, sizeof(params), &g), 0); ++ KUNIT_EXPECT_EQ(test, g.caus, 2U); ++ KUNIT_EXPECT_EQ(test, g.slc_pages, 128U); ++ KUNIT_EXPECT_EQ(test, (u64)g.blocks * g.pages * H2FMI_PAGE_DATA, 4462739456ULL); ++ h2fmi_test_params(params, ppn_64gb); ++ KUNIT_ASSERT_EQ(test, h2fmi_parse_geometry(params, sizeof(params), &g), 0); ++ KUNIT_EXPECT_EQ(test, g.caus, 8U); ++ KUNIT_EXPECT_EQ(test, g.slc_pages, 64U); ++ KUNIT_EXPECT_EQ(test, (u64)g.blocks * g.pages * H2FMI_PAGE_DATA, 4378853376ULL); ++ KUNIT_EXPECT_EQ(test, 2ULL * g.caus * g.blocks * g.pages * H2FMI_PAGE_DATA, ++ 70061654016ULL); ++} ++ ++static void h2fmi_invalid_geometry_test(struct kunit *test) ++{ ++ u8 params[H2FMI_PARAMS_SIZE]; ++ struct h2fmi_geometry g, sentinel; ++ int i; ++ ++ memset(&sentinel, 0xa5, sizeof(sentinel)); ++ for (i = 0; i < ARRAY_SIZE(ppn_64gb); i++) { ++ h2fmi_test_params(params, ppn_64gb); ++ put_unaligned_le32(0, params + 0x10 + i * sizeof(u32)); ++ g = sentinel; ++ KUNIT_EXPECT_LT(test, h2fmi_parse_geometry(params, sizeof(params), &g), 0); ++ KUNIT_EXPECT_MEMEQ(test, &g, &sentinel, sizeof(g)); ++ } ++ h2fmi_test_params(params, ppn_64gb); ++ KUNIT_EXPECT_EQ(test, h2fmi_parse_geometry(params, sizeof(params) - 1, &g), -EINVAL); ++ KUNIT_EXPECT_EQ(test, h2fmi_parse_geometry(params, 0, &g), -EINVAL); ++ params[0] ^= 1; ++ KUNIT_EXPECT_EQ(test, h2fmi_parse_geometry(params, sizeof(params), &g), -EINVAL); ++} ++ ++static void h2fmi_row_test(struct kunit *test) ++{ ++ u8 params[H2FMI_PARAMS_SIZE]; ++ struct h2fmi_geometry g; ++ u32 row, previous = 0; ++ unsigned int cau, block, page; ++ ++ h2fmi_test_params(params, ppn_64gb); ++ KUNIT_ASSERT_EQ(test, h2fmi_parse_geometry(params, sizeof(params), &g), 0); ++ KUNIT_ASSERT_EQ(test, h2fmi_page_row(&g, 0, 2048, 0, &row), 0); ++ KUNIT_EXPECT_EQ(test, row, 0x40000U); ++ KUNIT_ASSERT_EQ(test, h2fmi_page_row(&g, 1, 0, 0, &row), 0); ++ KUNIT_EXPECT_EQ(test, row, 0x80000U); ++ for (cau = 0; cau < g.caus; cau++) { ++ for (block = 0; block < g.blocks; block++) { ++ for (page = 0; page < g.pages; page++) { ++ h2fmi_page_row(&g, cau, block, page, &row); ++ if (cau || block || page) { ++ if (row <= previous) { ++ KUNIT_FAIL(test, "PPN row alias"); ++ return; ++ } ++ } ++ previous = row; ++ } ++ } ++ } ++ KUNIT_EXPECT_EQ(test, previous, 0x3c13ffU); ++ row = 0xdeadbeef; ++ KUNIT_EXPECT_EQ(test, h2fmi_page_row(&g, 8, 0, 0, &row), -EINVAL); ++ KUNIT_EXPECT_EQ(test, h2fmi_page_row(&g, 0, 2088, 0, &row), -EINVAL); ++ KUNIT_EXPECT_EQ(test, h2fmi_page_row(&g, 0, 0, 128, &row), -EINVAL); ++ KUNIT_EXPECT_EQ(test, row, 0xdeadbeefU); ++ h2fmi_test_params(params, ppn_16gb); ++ KUNIT_ASSERT_EQ(test, h2fmi_parse_geometry(params, sizeof(params), &g), 0); ++ KUNIT_ASSERT_EQ(test, h2fmi_page_row(&g, 1, 1063, 255, &row), 0); ++ KUNIT_EXPECT_EQ(test, row, 0xc27ffU); ++} ++ ++static void h2fmi_slc_row_test(struct kunit *test) ++{ ++ u8 params[H2FMI_PARAMS_SIZE]; ++ struct h2fmi_geometry g; ++ u32 row, previous = 0; ++ unsigned int cau, block, page; ++ ++ h2fmi_test_params(params, ppn_64gb); ++ KUNIT_ASSERT_EQ(test, h2fmi_parse_geometry(params, sizeof(params), &g), 0); ++ KUNIT_ASSERT_EQ(test, h2fmi_slc_row(&g, 0, 2087, 0, &row), 0); ++ KUNIT_EXPECT_EQ(test, row, 0x441380U); ++ for (cau = 0; cau < g.caus; cau++) { ++ for (block = 0; block < g.blocks; block++) { ++ for (page = 0; page < g.slc_pages; page++) { ++ h2fmi_slc_row(&g, cau, block, page, &row); ++ if (row <= previous || !(row & 0x400000)) { ++ KUNIT_FAIL(test, "PPN SLC row alias or mode bit lost"); ++ return; ++ } ++ previous = row; ++ } ++ } ++ } ++ KUNIT_EXPECT_EQ(test, previous, 0x7c13bfU); ++ row = 0xdeadbeef; ++ KUNIT_EXPECT_EQ(test, h2fmi_slc_row(&g, 8, 0, 0, &row), -EINVAL); ++ KUNIT_EXPECT_EQ(test, h2fmi_slc_row(&g, 0, 2088, 0, &row), -EINVAL); ++ KUNIT_EXPECT_EQ(test, h2fmi_slc_row(&g, 0, 0, 64, &row), -EINVAL); ++ KUNIT_EXPECT_EQ(test, row, 0xdeadbeefU); ++ h2fmi_test_params(params, ppn_16gb); ++ KUNIT_ASSERT_EQ(test, h2fmi_parse_geometry(params, sizeof(params), &g), 0); ++ KUNIT_ASSERT_EQ(test, h2fmi_slc_row(&g, 1, 1063, 127, &row), 0); ++ KUNIT_EXPECT_EQ(test, row, 0x8c277fU); ++ KUNIT_EXPECT_EQ(test, h2fmi_slc_row(&g, 0, 0, 128, &row), -EINVAL); ++} ++ ++static void h2fmi_status_test(struct kunit *test) ++{ ++ unsigned int status; ++ ++ for (status = 0; status <= 0xff; status++) { ++ int result = h2fmi_page_status(status); ++ ++ if (status == PPN_ST_GOOD || status == PPN_ST_ERASED) ++ KUNIT_EXPECT_EQ(test, result, 0); ++ else if (status == PPN_ST_REFRESH) ++ KUNIT_EXPECT_EQ(test, result, 1); ++ else ++ KUNIT_EXPECT_EQ(test, result, -EBADMSG); ++ } ++} ++ ++static struct kunit_case h2fmi_test_cases[] = { ++ KUNIT_CASE(h2fmi_geometry_test), ++ KUNIT_CASE(h2fmi_invalid_geometry_test), ++ KUNIT_CASE(h2fmi_row_test), ++ KUNIT_CASE(h2fmi_status_test), ++ KUNIT_CASE(h2fmi_slc_row_test), ++ {} ++}; ++ ++static struct kunit_suite h2fmi_test_suite = { ++ .name = "apple-h2fmi", ++ .test_cases = h2fmi_test_cases, ++}; ++ ++kunit_test_suite(h2fmi_test_suite); ++ ++MODULE_LICENSE("GPL"); +diff --git a/drivers/mtd/devices/mtd-apple-h2fmi.c b/drivers/mtd/devices/mtd-apple-h2fmi.c +index bdb89a700..c83bc9624 100644 +--- a/drivers/mtd/devices/mtd-apple-h2fmi.c ++++ b/drivers/mtd/devices/mtd-apple-h2fmi.c +@@ -1,6 +1,6 @@ + // SPDX-License-Identifier: GPL-2.0-only + /* +- * Apple S5L H2FMI + Hynix PPN NAND -- READ ONLY. ++ * Apple S5L H2FMI + Hynix PPN NAND. + * + * The iPad mini 1 has two PPN packages, one on CE0 of each of the SoC's two + * flash buses. "PPN" means the package has its own controller: it does the +@@ -14,23 +14,22 @@ + * this is a port of, tools/nand/ppn.c, which read the whole NAND bit-for-bit + * repeatably. docs/research/p105-nand.md is the notebook. + * +- * Geometry: 2 buses x 1 CE x 2 CAUs x 1064 blocks x 256 pages. A page comes +- * off the bus as 16448 bytes -- four times (1024 data, 16 metadata, 3072 +- * data). The metadata is the FTL's, and becomes MTD's OOB; strip it and a +- * page is 16 KiB of data with 64 bytes of OOB. One MTD is exported per die, +- * which is also how the verified raw dumps in docs are laid out, so a fresh +- * read can be diffed against them byte for byte. ++ * Geometry is read from each PPN package before registering any MTDs. ++ * The supported packages have one CE per bus and either two or eight CAUs. ++ * A page has 16448 bytes: four chunks of 1024 data bytes, 16 metadata ++ * bytes and 3072 more data bytes. The metadata is the FTL's, and becomes MTD's OOB; strip it and a ++ * page is 16 KiB of data with 64 bytes of OOB. One MTD is exported per ++ * CAU. Raw MTD offsets are independent of the iOS logical block addresses. + * +- * READ ONLY, and structurally so: the opcode allowlist below has no program +- * (80/10, PPN boot page 8A/17) and no erase (60/D0, PPN 67) in it, and there +- * is no code path that could issue one. Writing is a separate job that costs +- * iOS the first time it runs; see the README. ++ * This interface exposes reads through MTD. Logical-address translation and ++ * allocation belong to the shared FTL above the physical page provider. + */ + + #include + #include + #include + #include ++#include + #include + #include + #include +@@ -38,31 +37,10 @@ + #include + #include + ++#include "mtd-apple-h2fmi.h" ++ + #define H2FMI_BUSES 2 +-#define H2FMI_CAUS 2 +-#define H2FMI_DIES (H2FMI_BUSES * H2FMI_CAUS) +-#define H2FMI_BLOCKS 1064 +-#define H2FMI_PAGES 256 +- +-#define H2FMI_CHUNKS 4 /* per page */ +-#define H2FMI_CHUNK_LO 1024 /* data before the metadata */ +-#define H2FMI_CHUNK_META 16 +-#define H2FMI_CHUNK_HI 3072 /* data after it */ +-#define H2FMI_CHUNK_RAW (H2FMI_CHUNK_LO + H2FMI_CHUNK_META + H2FMI_CHUNK_HI) +- +-#define H2FMI_PAGE_RAW (H2FMI_CHUNKS * H2FMI_CHUNK_RAW) /* 16448 */ +-#define H2FMI_PAGE_DATA (H2FMI_CHUNKS * (H2FMI_CHUNK_LO + H2FMI_CHUNK_HI)) +-#define H2FMI_PAGE_OOB (H2FMI_CHUNKS * H2FMI_CHUNK_META) /* 64 */ +-#define H2FMI_ERASESIZE ((u32)H2FMI_PAGES * H2FMI_PAGE_DATA) /* 4 MiB */ +- +-/* Data page and erase block are both powers of two (16 KiB and 4 MiB), so an +- * offset splits with shifts and masks. This is a 32-bit kernel and the MTD is +- * 4 GiB, so every offset is 64-bit: a plain / or % here would be a link error +- * for __aeabi_uldivmod, which is exactly the kind of thing that only shows up +- * at the end of a build. */ +-#define H2FMI_PAGE_SHIFT 14 +-#define H2FMI_PAGES_SHIFT 8 /* 256 pages a block */ +-#define H2FMI_PAGE_MASK ((u32)H2FMI_PAGE_DATA - 1) ++#define H2FMI_PAGE_MASK (H2FMI_PAGE_DATA - 1) + + /* The ADT's ce-bitmap is 0x101: CE0 on each bus, and nothing else. Addressing + * a CE with no chip behind it wedges the FMC in DDR -- it waits for a DQS that +@@ -91,22 +69,11 @@ + #define FMC_NAND_STATUS 0x48 + #define FMC_STATUS_MASK 0x4c + +-/* Page status: 0x40 good, 0x42 good but due a refresh, 0x45 every page of a +- * retired block, 0x49 erased. */ +-#define PPN_ST_GOOD 0x40 +-#define PPN_ST_REFRESH 0x42 +-#define PPN_ST_RETIRED 0x45 +-#define PPN_ST_ERASED 0x49 +- +-/* row for the page-read command */ +-#define PPN_ROW(cau, blk, pg) \ +- ((u32)(pg) | (u32)(blk) << 8 | (u32)(cau) << 19) +- + /* The allowlist is the safety property, not a convenience: a sequence that is + * not on it does not run. Nothing here programs or erases. */ + static const u8 h2fmi_read_only_ops[][2] = { +- { 0xff, 0x00 }, /* reset */ + { 0x70, 0x00 }, /* read status */ ++ { 0x92, 0x97 }, /* PPN device parameters */ + { 0x77, 0x7d }, /* PPN operation status */ + { 0x77, 0x00 }, /* PPN end of operation */ + { 0x7a, 0x00 }, /* PPN data out */ +@@ -119,6 +86,8 @@ struct h2fmi_bus { + void __iomem *ecc; + struct mutex lock; /* one command sequence on this bus at a time */ + u8 *page; /* one raw page; only touched under lock */ ++ struct h2fmi_geometry geometry; ++ bool failed; /* block further I/O in this driver instance */ + }; + + struct h2fmi_die { +@@ -130,8 +99,9 @@ struct h2fmi_die { + struct h2fmi { + struct device *dev; + struct h2fmi_bus bus[H2FMI_BUSES]; +- struct mtd_info mtd[H2FMI_DIES]; +- struct h2fmi_die die[H2FMI_DIES]; ++ struct mtd_info *mtd; ++ struct h2fmi_die *die; ++ unsigned int ndies; + }; + + /* +@@ -156,7 +126,7 @@ static int h2fmi_wait(struct h2fmi *fmi, void __iomem *base, u32 off, + + /* + * FMC_CMD holds the first command byte, and the second one in bits 15:8. +- * Every sequence goes through here, which is where the allowlist bites. ++ * Every command sequence passes through the read-operation allowlist. + */ + static int h2fmi_cmd_write(struct h2fmi *fmi, struct h2fmi_bus *b, u8 op, u8 op2) + { +@@ -196,8 +166,11 @@ static int h2fmi_cmd1(struct h2fmi *fmi, struct h2fmi_bus *b, u8 op) + static int h2fmi_cmd_addr_cmd(struct h2fmi *fmi, struct h2fmi_bus *b, + u8 op, u8 op2, u32 addr, int naddr) + { +- int ret = h2fmi_cmd_write(fmi, b, op, op2); ++ int ret; + ++ if (naddr < 1 || naddr > sizeof(addr)) ++ return -EINVAL; ++ ret = h2fmi_cmd_write(fmi, b, op, op2); + if (ret) + return ret; + writel(addr, b->fmc + FMC_ADDR0); +@@ -306,27 +279,61 @@ static void h2fmi_device_reset(struct h2fmi_bus *b, u32 on, u32 if_ctrl) + writel(if_ctrl, b->fmc + FMC_IF_CTRL); + } + +-/* One raw page, into b->page. Caller holds b->lock. */ +-static int h2fmi_read_raw_page(struct h2fmi *fmi, struct h2fmi_bus *b, +- u32 row, u8 *st) ++/* Caller holds b->lock. All exits release CE and restore inherited state. */ ++static int h2fmi_read_transaction(struct h2fmi *fmi, struct h2fmi_bus *b, ++ u8 op, u8 op2, u32 address, int naddr, ++ u8 *buffer, u32 length, u8 *status) + { +- u32 on = readl(b->fmc + FMC_ON); +- u32 if_ctrl = readl(b->fmc + FMC_IF_CTRL); +- int ret; ++ u32 on, if_ctrl; ++ int ret, end_ret; ++ ++ *status = 0; ++ if (b->failed) ++ return -EIO; ++ on = readl(b->fmc + FMC_ON); ++ if_ctrl = readl(b->fmc + FMC_IF_CTRL); ++ if (on != 5) ++ return -EIO; + +- *st = 0; + writel(1u << H2FMI_CE, b->fmc + FMC_CE_CTRL); + h2fmi_clear_irqs(b); +- +- ret = h2fmi_cmd_addr_cmd(fmi, b, 0x0a, 0x37, row, 3); +- if (!ret) +- ret = h2fmi_status(fmi, b, st); ++ ret = h2fmi_cmd_addr_cmd(fmi, b, op, op2, address, naddr); + if (!ret) +- ret = h2fmi_data_out(fmi, b, b->page, H2FMI_PAGE_RAW); +- +- h2fmi_cmd1(fmi, b, 0x77); ++ ret = h2fmi_status(fmi, b, status); ++ /* An error status has no data phase. Device parameters require GOOD; ++ * normal pages also admit REFRESH and ERASED. Keep the status for the ++ * MTD reader and bad-block query without copying a stale page buffer. ++ */ ++ if (!ret && (op == 0x92 ? *status == PPN_ST_GOOD : ++ h2fmi_page_status(*status) >= 0)) ++ ret = h2fmi_data_out(fmi, b, buffer, length); ++ ++ /* Stop PIO before ending the operation, as in the parameter reader. */ ++ writel(6, b->fmi + FMI_CONTROL); ++ end_ret = h2fmi_cmd1(fmi, b, 0x77); + writel(0, b->fmc + FMC_CE_CTRL); + h2fmi_device_reset(b, on, if_ctrl); ++ if (ret || end_ret) { ++ b->failed = true; ++ return ret ? ret : end_ret; ++ } ++ return 0; ++} ++ ++static int h2fmi_read_geometry(struct h2fmi *fmi, struct h2fmi_bus *b) ++{ ++ u8 status = 0; ++ int ret; ++ ++ mutex_lock(&b->lock); ++ ret = h2fmi_read_transaction(fmi, b, 0x92, 0x97, 0, 1, ++ b->page, H2FMI_PARAMS_SIZE, &status); ++ if (!ret && status != PPN_ST_GOOD) ++ ret = -EIO; ++ if (!ret) ++ ret = h2fmi_parse_geometry(b->page, H2FMI_PARAMS_SIZE, ++ &b->geometry); ++ mutex_unlock(&b->lock); + return ret; + } + +@@ -353,14 +360,6 @@ static void h2fmi_split_page(const u8 *raw, u8 *data, u8 *oob) + } + } + +-/* +- * The one place that turns an MTD offset into a page and reads it. Whole +- * pages are always read -- the PPN cannot hand over less than one -- and the +- * caller takes what it wants out of the bounce buffer. +- * +- * `from` must be page-aligned. The page's status byte comes back in *stp; a +- * negative return means the bus failed, not that the data is bad. +- */ + static int h2fmi_read_page_at(struct mtd_info *mtd, loff_t from, + u8 *data, u8 *oob, u8 *stp) + { +@@ -368,18 +367,31 @@ static int h2fmi_read_page_at(struct mtd_info *mtd, loff_t from, + struct h2fmi *fmi = die->fmi; + struct h2fmi_bus *b = &fmi->bus[die->bus]; + u64 page = (u64)from >> H2FMI_PAGE_SHIFT; +- u32 blk = (u32)(page >> H2FMI_PAGES_SHIFT); +- u32 pg = (u32)page & (H2FMI_PAGES - 1); ++ u32 blk = page >> b->geometry.page_bits; ++ u32 pg = page & (b->geometry.pages - 1); ++ u32 row; + u8 st = 0; + int ret; + +- if (blk >= H2FMI_BLOCKS) ++ if (from < 0 || from >= mtd->size || (from & H2FMI_PAGE_MASK)) + return -EINVAL; ++ ret = h2fmi_page_row(&b->geometry, die->cau, blk, pg, &row); ++ if (ret) ++ return ret; + + mutex_lock(&b->lock); +- ret = h2fmi_read_raw_page(fmi, b, PPN_ROW(die->cau, blk, pg), &st); +- if (!ret) +- h2fmi_split_page(b->page, data, oob); ++ /* Supported PPN 1.5 packages require four row cycles, even when the ++ * fourth byte is zero. This is independent of the geometry's width. ++ */ ++ ret = h2fmi_read_transaction(fmi, b, 0x0a, 0x37, row, 4, ++ b->page, H2FMI_PAGE_RAW, &st); ++ if (!ret && h2fmi_page_status(st) >= 0) { ++ if (st == PPN_ST_ERASED && ++ memchr_inv(b->page, 0xff, H2FMI_PAGE_RAW)) ++ ret = -EBADMSG; ++ else ++ h2fmi_split_page(b->page, data, oob); ++ } + mutex_unlock(&b->lock); + + *stp = st; +@@ -398,17 +410,12 @@ static int h2fmi_read_page_at(struct mtd_info *mtd, loff_t from, + */ + static int h2fmi_bitflips(struct mtd_info *mtd, u8 st) + { +- switch (st) { +- case PPN_ST_GOOD: +- case PPN_ST_ERASED: +- return 0; +- case PPN_ST_REFRESH: +- return 1; +- default: ++ int ret = h2fmi_page_status(st); ++ ++ if (ret < 0) + dev_err_ratelimited(mtd->dev.parent, + "%s: page status 0x%02x\n", mtd->name, st); +- return -EBADMSG; +- } ++ return ret; + } + + /* +@@ -463,14 +470,22 @@ static int h2fmi_mtd_read_oob(struct mtd_info *mtd, loff_t from, + } + + ret = h2fmi_read_page_at(mtd, base, dst, oob, &st); +- if (ret) ++ if (ret) { ++ /* MEMREAD retries ECC errors. No bytes were returned, so ++ * report a terminal error instead of repeating this page. ++ */ ++ if (ret == -EBADMSG) ++ ret = -EIO; + break; ++ } + + bf = h2fmi_bitflips(mtd, st); + if (bf < 0) { +- ret = bf; ++ mtd->ecc_stats.failed++; ++ ret = -EIO; + break; + } ++ mtd->ecc_stats.corrected += bf; + maxbitflips = max(maxbitflips, bf); + + if (len) { +@@ -514,27 +529,20 @@ static int h2fmi_mtd_block_isbad(struct mtd_info *mtd, loff_t ofs) + ret = h2fmi_read_page_at(mtd, ofs, NULL, NULL, &st); + if (ret) + return ret; +- switch (st) { +- case PPN_ST_GOOD: +- case PPN_ST_REFRESH: +- case PPN_ST_ERASED: +- return 0; +- case PPN_ST_RETIRED: ++ if (st == PPN_ST_RETIRED) + return 1; +- default: +- /* An unknown status does not establish that the block is good. */ +- return -EIO; +- } ++ return h2fmi_page_status(st) < 0 ? -EIO : 0; + } + + static int h2fmi_setup_mtd(struct h2fmi *fmi, int idx) + { + struct mtd_info *mtd = &fmi->mtd[idx]; + struct h2fmi_die *die = &fmi->die[idx]; ++ const struct h2fmi_geometry *g = &fmi->bus[0].geometry; + + die->fmi = fmi; +- die->bus = idx / H2FMI_CAUS; +- die->cau = idx % H2FMI_CAUS; ++ die->bus = idx / g->caus; ++ die->cau = idx % g->caus; + + mtd->priv = die; + mtd->dev.parent = fmi->dev; +@@ -544,14 +552,10 @@ static int h2fmi_setup_mtd(struct h2fmi *fmi, int idx) + return -ENOMEM; + + mtd->type = MTD_NANDFLASH; +- /* No MTD_WRITEABLE on purpose: this driver cannot write, and the NAND +- * it is pointed at holds somebody's iOS. MTD_NO_ERASE goes with it -- +- * add_mtd_device() rejects an erasesize with no ->_erase behind it +- * unless the device says erasing is not a thing it does. Both come off +- * when the write path lands. */ ++ /* PPN page reads expose the package metadata through MTD OOB. */ + mtd->flags = MTD_NO_ERASE; +- mtd->size = (u64)H2FMI_BLOCKS * H2FMI_ERASESIZE; +- mtd->erasesize = H2FMI_ERASESIZE; ++ mtd->erasesize = g->pages * H2FMI_PAGE_DATA; ++ mtd->size = (u64)g->blocks * mtd->erasesize; + mtd->writesize = H2FMI_PAGE_DATA; + mtd->writebufsize = H2FMI_PAGE_DATA; + mtd->oobsize = H2FMI_PAGE_OOB; +@@ -578,7 +582,6 @@ static int h2fmi_probe(struct platform_device *pdev) + int i, ret; + + BUILD_BUG_ON(H2FMI_PAGE_DATA != (1 << H2FMI_PAGE_SHIFT)); +- BUILD_BUG_ON(H2FMI_PAGES != (1 << H2FMI_PAGES_SHIFT)); + BUILD_BUG_ON(H2FMI_PAGE_RAW != 16448); + + fmi = devm_kzalloc(&pdev->dev, sizeof(*fmi), GFP_KERNEL); +@@ -612,7 +615,24 @@ static int h2fmi_probe(struct platform_device *pdev) + i, readl(b->fmc + FMC_ON), readl(b->fmc + FMC_IF_CTRL)); + } + +- for (i = 0; i < H2FMI_DIES; i++) { ++ /* Query both packages before publishing any part of the inventory. */ ++ for (i = 0; i < H2FMI_BUSES; i++) { ++ ret = h2fmi_read_geometry(fmi, &fmi->bus[i]); ++ if (ret) ++ return dev_err_probe(&pdev->dev, ret, ++ "bus %d: PPN discovery failed\n", i); ++ if (i && memcmp(&fmi->bus[0].geometry, &fmi->bus[i].geometry, ++ sizeof(fmi->bus[0].geometry))) ++ return dev_err_probe(&pdev->dev, -EINVAL, ++ "PPN package geometries disagree\n"); ++ } ++ fmi->ndies = H2FMI_BUSES * fmi->bus[0].geometry.caus; ++ fmi->mtd = devm_kcalloc(&pdev->dev, fmi->ndies, sizeof(*fmi->mtd), GFP_KERNEL); ++ fmi->die = devm_kcalloc(&pdev->dev, fmi->ndies, sizeof(*fmi->die), GFP_KERNEL); ++ if (!fmi->mtd || !fmi->die) ++ return -ENOMEM; ++ ++ for (i = 0; i < fmi->ndies; i++) { + ret = h2fmi_setup_mtd(fmi, i); + if (ret) { + while (--i >= 0) +@@ -622,8 +642,9 @@ static int h2fmi_probe(struct platform_device *pdev) + } + + platform_set_drvdata(pdev, fmi); +- dev_info(&pdev->dev, "%d dies, %u blocks of %u KiB each, read-only\n", +- H2FMI_DIES, H2FMI_BLOCKS, H2FMI_ERASESIZE / 1024); ++ dev_info(&pdev->dev, "%u CAUs, %u blocks of %u KiB each\n", ++ fmi->ndies, fmi->bus[0].geometry.blocks, ++ fmi->mtd[0].erasesize / 1024); + return 0; + } + +@@ -632,7 +653,7 @@ static void h2fmi_remove(struct platform_device *pdev) + struct h2fmi *fmi = platform_get_drvdata(pdev); + int i; + +- for (i = 0; i < H2FMI_DIES; i++) ++ for (i = 0; i < fmi->ndies; i++) + mtd_device_unregister(&fmi->mtd[i]); + } + +@@ -652,5 +673,5 @@ static struct platform_driver h2fmi_driver = { + }; + module_platform_driver(h2fmi_driver); + +-MODULE_DESCRIPTION("Apple S5L H2FMI / Hynix PPN NAND, read-only"); ++MODULE_DESCRIPTION("Apple S5L H2FMI / Hynix PPN NAND"); + MODULE_LICENSE("GPL"); +diff --git a/drivers/mtd/devices/mtd-apple-h2fmi.h b/drivers/mtd/devices/mtd-apple-h2fmi.h +new file mode 100644 +index 000000000..ad573a5c0 +--- /dev/null ++++ b/drivers/mtd/devices/mtd-apple-h2fmi.h +@@ -0,0 +1,122 @@ ++/* SPDX-License-Identifier: GPL-2.0-only */ ++#ifndef MTD_APPLE_H2FMI_H ++#define MTD_APPLE_H2FMI_H ++ ++#include ++#include ++#include ++#include ++ ++#define H2FMI_PARAMS_SIZE 512 ++#define H2FMI_CHUNKS 4 ++#define H2FMI_CHUNK_LO 1024 ++#define H2FMI_CHUNK_META 16 ++#define H2FMI_CHUNK_HI 3072 ++#define H2FMI_CHUNK_RAW 4112 ++#define H2FMI_PAGE_RAW 16448 ++#define H2FMI_PAGE_DATA 16384 ++#define H2FMI_PAGE_OOB 64 ++#define H2FMI_PAGE_SHIFT 14 ++ ++#define PPN_ST_GOOD 0x40 ++#define PPN_ST_REFRESH 0x42 ++#define PPN_ST_RETIRED 0x45 ++#define PPN_ST_ERASED 0x49 ++ ++struct h2fmi_geometry { ++ u32 caus; ++ u32 blocks; ++ u32 pages; ++ u32 page_bits; ++ u32 block_bits; ++ u32 cau_bits; ++ u32 slc_pages; ++}; ++ ++/* Restrict discovery to the two measured PPN 1.5 package profiles. The ++ * parameter response does not identify a PPN protocol revision. The existing ++ * platform compatible continues to select that protocol explicitly. ++ */ ++static inline int h2fmi_parse_geometry(const u8 *params, size_t len, ++ struct h2fmi_geometry *geometry) ++{ ++ struct h2fmi_geometry g; ++ u32 cell_bits, cells, raw_bytes; ++ ++ if (len != H2FMI_PARAMS_SIZE || ++ memcmp(params, "PPN Device Info", sizeof("PPN Device Info") - 1)) ++ return -EINVAL; ++ ++ g.caus = get_unaligned_le32(params + 0x10); ++ g.cau_bits = get_unaligned_le32(params + 0x14); ++ g.blocks = get_unaligned_le32(params + 0x18); ++ g.block_bits = get_unaligned_le32(params + 0x1c); ++ g.pages = get_unaligned_le32(params + 0x20); ++ g.slc_pages = get_unaligned_le32(params + 0x24); ++ g.page_bits = get_unaligned_le32(params + 0x28); ++ cell_bits = get_unaligned_le32(params + 0x2c); ++ cells = get_unaligned_le32(params + 0x30); ++ raw_bytes = get_unaligned_le32(params + 0x34); ++ if (raw_bytes != H2FMI_PAGE_RAW || cell_bits != 1 || cells != 2) ++ return -EOPNOTSUPP; ++ if (g.caus == 2 && g.cau_bits == 4 && g.blocks == 1064 && ++ g.block_bits == 11 && g.pages == 256 && g.slc_pages == 128 && ++ g.page_bits == 8) { ++ *geometry = g; ++ return 0; ++ } ++ if (g.caus == 8 && g.cau_bits == 3 && g.blocks == 2088 && ++ g.block_bits == 12 && g.pages == 128 && g.slc_pages == 64 && ++ g.page_bits == 7) { ++ *geometry = g; ++ return 0; ++ } ++ return -EOPNOTSUPP; ++} ++ ++static inline int h2fmi_page_row(const struct h2fmi_geometry *g, ++ unsigned int cau, unsigned int block, ++ unsigned int page, u32 *row) ++{ ++ if (cau >= g->caus || block >= g->blocks || page >= g->pages) ++ return -EINVAL; ++ *row = page | block << g->page_bits | ++ cau << (g->page_bits + g->block_bits); ++ return 0; ++} ++ ++/* The VFL bootstrap's packed requests set the bit above the CAU field. ++ * Keep this view separate from normal MTD offsets. Its address encoding ++ * follows the saved firmware; live context selection is a separate step. ++ */ ++static inline int h2fmi_slc_row(const struct h2fmi_geometry *g, ++ unsigned int cau, unsigned int block, ++ unsigned int page, u32 *row) ++{ ++ u32 value; ++ int ret; ++ ++ if (page >= g->slc_pages) ++ return -EINVAL; ++ ret = h2fmi_page_row(g, cau, block, page, &value); ++ if (ret) ++ return ret; ++ *row = value | 1u << (g->page_bits + g->block_bits + g->cau_bits); ++ return 0; ++} ++ ++/* Unknown statuses must never lead to a data-out command or a good block. */ ++static inline int h2fmi_page_status(u8 status) ++{ ++ switch (status) { ++ case PPN_ST_GOOD: ++ case PPN_ST_ERASED: ++ return 0; ++ case PPN_ST_REFRESH: ++ return 1; ++ default: ++ return -EBADMSG; ++ } ++} ++ ++#endif +diff --git a/tools/testing/kunit/configs/apple_h2fmi.config b/tools/testing/kunit/configs/apple_h2fmi.config +new file mode 100644 +index 000000000..8577f01ee +--- /dev/null ++++ b/tools/testing/kunit/configs/apple_h2fmi.config +@@ -0,0 +1,3 @@ ++CONFIG_KUNIT=y ++CONFIG_MTD=y ++CONFIG_MTD_APPLE_H2FMI_KUNIT_TEST=y diff --git a/kernel/patches/drivers/mtd/0001-mtd-apple-h2fmi-Discover-and-validate-PPN-package-ge.patch.toml b/kernel/patches/drivers/mtd/0001-mtd-apple-h2fmi-Discover-and-validate-PPN-package-ge.patch.toml new file mode 100644 index 00000000..1248e2bf --- /dev/null +++ b/kernel/patches/drivers/mtd/0001-mtd-apple-h2fmi-Discover-and-validate-PPN-package-ge.patch.toml @@ -0,0 +1,2 @@ +schema = 1 +platforms = ["apple-armv7"] diff --git a/kernel/patches/drivers/mtd/0002-mtd-apple-h2fmi-Add-verified-complete-page-programmi.patch b/kernel/patches/drivers/mtd/0002-mtd-apple-h2fmi-Add-verified-complete-page-programmi.patch new file mode 100644 index 00000000..41098896 --- /dev/null +++ b/kernel/patches/drivers/mtd/0002-mtd-apple-h2fmi-Add-verified-complete-page-programmi.patch @@ -0,0 +1,385 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> +Date: Fri, 6 Nov 2026 11:49:24 -0600 +Subject: [PATCH] mtd: apple-h2fmi: Add verified complete-page programming + +Add the measured PPN program sequence behind a default-off Kconfig option. +Require complete data and metadata, an erased destination and exact readback +before publishing progress. A transport failure, uncertain completion or +readback mismatch stops further I/O on that bus. + +Use the native program-completion status rule rather than the managed-read +status classifier. Keep allocation and shared FTL ownership policy outside +this controller interface. The driver supplies no erase operation. + +The actual program functions passed 134181 ASan/UBSan assertions and the read +path retained its 140146 assertions. Focused ARMv7 driver compilation passed +with programming enabled and disabled; KUnit source compilation passed. +The exact new candidate still needs hardware qualification. + +Assisted-by: LLM +--- + drivers/mtd/devices/Kconfig | 18 +- + drivers/mtd/devices/mtd-apple-h2fmi-test.c | 14 ++ + drivers/mtd/devices/mtd-apple-h2fmi.c | 237 +++++++++++++++++++++ + drivers/mtd/devices/mtd-apple-h2fmi.h | 7 + + 4 files changed, 272 insertions(+), 4 deletions(-) + +diff --git a/drivers/mtd/devices/Kconfig b/drivers/mtd/devices/Kconfig +index 2b6e1efae..637358e20 100644 +--- a/drivers/mtd/devices/Kconfig ++++ b/drivers/mtd/devices/Kconfig +@@ -207,7 +207,7 @@ config MTD_ST_SPI_FSM + for a subset of connected Serial Flash devices. + + config MTD_APPLE_H2FMI +- tristate "Apple S5L H2FMI / PPN NAND (read-only)" ++ tristate "Apple S5L H2FMI / PPN NAND" + depends on ARCH_APPLE_S5L || COMPILE_TEST + help + Apple's flash controller as it appears on the A5 (S5L8940X/8942X), +@@ -215,15 +215,25 @@ config MTD_APPLE_H2FMI + package does its own ECC, so this is a plain MTD and not a raw-NAND + driver. One MTD per CAU; geometry is read from each package. + +- READ ONLY. The driver's opcode allowlist contains no program and no +- erase, so it cannot damage whatever the NAND already holds -- which +- on an un-restored device is somebody's iOS install. ++ The default interface is read-only. Optional verified full-page ++ programming requires MTD_APPLE_H2FMI_WRITE. An Apple-compatible FTL ++ must own allocation and recovery before modifying shared NAND. + + Say Y to read the NAND from Linux, for instance to check a dump or + to mount iOS's own partitions. + + If unsure, say N. + ++config MTD_APPLE_H2FMI_WRITE ++ bool "Apple H2FMI PPN page programming (EXPERIMENTAL)" ++ depends on MTD_APPLE_H2FMI ++ default n ++ help ++ Build synchronous full-page programming with data and metadata ++ readback. A destination must be erased; erase operations are not ++ implemented. Allocation and recovery belong to an Apple-compatible ++ FTL that owns the shared NAND. Enable this only with such a caller. ++ + config MTD_APPLE_H2FMI_KUNIT_TEST + tristate "KUnit tests for Apple H2FMI PPN geometry" if !KUNIT_ALL_TESTS + depends on KUNIT +diff --git a/drivers/mtd/devices/mtd-apple-h2fmi-test.c b/drivers/mtd/devices/mtd-apple-h2fmi-test.c +index 5308972e7..419c04cc8 100644 +--- a/drivers/mtd/devices/mtd-apple-h2fmi-test.c ++++ b/drivers/mtd/devices/mtd-apple-h2fmi-test.c +@@ -153,12 +153,26 @@ static void h2fmi_status_test(struct kunit *test) + } + } + ++static void h2fmi_program_status_test(struct kunit *test) ++{ ++ unsigned int status; ++ ++ for (status = 0; status <= 0xff; status++) { ++ bool ready = status & 0x40; ++ bool failed = status & 0x11; ++ ++ KUNIT_EXPECT_EQ(test, h2fmi_program_status(status), ++ ready && !failed ? 0 : -EIO); ++ } ++} ++ + static struct kunit_case h2fmi_test_cases[] = { + KUNIT_CASE(h2fmi_geometry_test), + KUNIT_CASE(h2fmi_invalid_geometry_test), + KUNIT_CASE(h2fmi_row_test), + KUNIT_CASE(h2fmi_status_test), + KUNIT_CASE(h2fmi_slc_row_test), ++ KUNIT_CASE(h2fmi_program_status_test), + {} + }; + +diff --git a/drivers/mtd/devices/mtd-apple-h2fmi.c b/drivers/mtd/devices/mtd-apple-h2fmi.c +index c83bc9624..6f17833a3 100644 +--- a/drivers/mtd/devices/mtd-apple-h2fmi.c ++++ b/drivers/mtd/devices/mtd-apple-h2fmi.c +@@ -23,6 +23,8 @@ + * + * This interface exposes reads through MTD. Logical-address translation and + * allocation belong to the shared FTL above the physical page provider. ++ * Optional programming accepts an erased full page and verifies its raw ++ * data and metadata synchronously before reporting completion. + */ + + #include +@@ -360,6 +362,237 @@ static void h2fmi_split_page(const u8 *raw, u8 *data, u8 *oob) + } + } + ++static int h2fmi_write_cmd(struct h2fmi *fmi, struct h2fmi_bus *b, ++ u8 op, u8 op2) ++{ ++ if (!IS_ENABLED(CONFIG_MTD_APPLE_H2FMI_WRITE)) ++ return -EROFS; ++ if (!((op == 0x8a || op == 0x17 || op == 0x79) && !op2)) ++ return -EPERM; ++ writel(op | op2 << 8, b->fmc + FMC_CMD); ++ return 0; ++} ++ ++static int h2fmi_write_cmd1(struct h2fmi *fmi, struct h2fmi_bus *b, u8 op) ++{ ++ int ret = h2fmi_write_cmd(fmi, b, op, 0); ++ ++ if (ret) ++ return ret; ++ writel(1, b->fmc + FMC_RW_CTRL); ++ return h2fmi_wait(fmi, b->fmc, FMC_STATUS, 1, 1); ++} ++ ++/* Four row-address cycles follow the PPN program command. */ ++static int h2fmi_write_address(struct h2fmi *fmi, struct h2fmi_bus *b, ++ u32 row) ++{ ++ int ret = h2fmi_write_cmd(fmi, b, 0x8a, 0); ++ ++ if (ret) ++ return ret; ++ writel(row, b->fmc + FMC_ADDR0); ++ writel(3, b->fmc + FMC_ADDRNUM); ++ writel(9, b->fmc + FMC_RW_CTRL); ++ return h2fmi_wait(fmi, b->fmc, FMC_STATUS, 9, 9); ++} ++ ++/* Same PIO FIFO used by iBEC's feature writer, with CONTROL=5 for data in. ++ * Every chunk must finish before the next one or the program confirm. ++ */ ++static int h2fmi_data_in(struct h2fmi *fmi, struct h2fmi_bus *b, ++ const u8 *buffer, u32 length) ++{ ++ u32 offset, index, value; ++ int ret; ++ ++ if (!length || length & 3) ++ return -EINVAL; ++ for (offset = 0; offset < length; ) { ++ u32 bytes = min_t(u32, length - offset, 0x400); ++ ++ writel(5, b->fmi + FMI_CONFIG); ++ writel((bytes << 8 & 0x7fc00) | 1, ++ b->fmi + FMI_PIO_CONFIG); ++ writel(5, b->fmi + FMI_CONTROL); ++ ret = readl_poll_timeout(b->fmi + FMI_DMA_STATUS, value, ++ value & 0x18, 0, 100000); ++ if (ret) ++ return ret; ++ for (index = 0; index < bytes; index += 4) ++ writel(get_unaligned_le32(buffer + offset + index), ++ b->fmi + FMI_DATA); ++ ret = h2fmi_wait(fmi, b->fmi, FMI_STATUS, 2, 2); ++ if (ret) ++ return ret; ++ offset += bytes; ++ } ++ return 0; ++} ++ ++/* Program status uses 79, as in iBEC's write_bootpage path. */ ++static int h2fmi_write_status(struct h2fmi *fmi, struct h2fmi_bus *b, u8 *status) ++{ ++ u32 value; ++ int ret = h2fmi_write_cmd1(fmi, b, 0x79); ++ ++ if (ret) ++ return ret; ++ h2fmi_clear_irqs(b); ++ writel(0x100, b->fmi + FMI_INTEN); ++ writel(readl(b->fmc + FMC_IF_CTRL) & ~0x100000u, ++ b->fmc + FMC_IF_CTRL); ++ writel(0x4040, b->fmc + FMC_STATUS_MASK); ++ writel(0, b->fmc + FMC_DATANUM); ++ writel(0x20, b->fmc + FMC_INTMASK); ++ writel(0x50, b->fmc + FMC_RW_CTRL); ++ ret = readl_poll_timeout(b->fmc + FMC_STATUS, value, ++ value & 0x20, 0, 2000000); ++ if (!ret) ++ *status = readl(b->fmc + FMC_NAND_STATUS); ++ writel(0, b->fmc + FMC_RW_CTRL); ++ h2fmi_clear_irqs(b); ++ return ret; ++} ++ ++/* Caller holds the bus lock. A failed mutation permanently stops this bus. */ ++static int h2fmi_program_transaction(struct h2fmi *fmi, struct h2fmi_bus *b, ++ u32 row, const u8 *raw) ++{ ++ u32 on, if_ctrl; ++ u8 status = 0; ++ int ret; ++ ++ if (!IS_ENABLED(CONFIG_MTD_APPLE_H2FMI_WRITE)) ++ return -EROFS; ++ if (b->failed) ++ return -EIO; ++ on = readl(b->fmc + FMC_ON); ++ if_ctrl = readl(b->fmc + FMC_IF_CTRL); ++ if (on != 5) ++ return -EIO; ++ writel(1u << H2FMI_CE, b->fmc + FMC_CE_CTRL); ++ h2fmi_clear_irqs(b); ++ ret = h2fmi_write_address(fmi, b, row); ++ if (!ret) ++ ret = h2fmi_data_in(fmi, b, raw, H2FMI_PAGE_RAW); ++ if (!ret) ++ ret = h2fmi_write_cmd1(fmi, b, 0x17); ++ if (!ret) ++ ret = h2fmi_write_status(fmi, b, &status); ++ /* CMD 79 returns NAND write status, not the managed read result. ++ * iBEC 12H321 tests failure bits 0 and 4 after ready bit 6. ++ */ ++ if (!ret) ++ ret = h2fmi_program_status(status); ++ writel(0, b->fmc + FMC_CE_CTRL); ++ h2fmi_device_reset(b, on, if_ctrl); ++ if (ret) { ++ b->failed = true; ++ dev_err(fmi->dev, "PPN program row %08x: status %02x, error %d\n", ++ row, status, ret); ++ } ++ return ret; ++} ++ ++static void h2fmi_join_page(u8 *raw, const u8 *data, const u8 *oob) ++{ ++ unsigned int chunk; ++ ++ for (chunk = 0; chunk < H2FMI_CHUNKS; chunk++) { ++ u8 *dst = raw + chunk * H2FMI_CHUNK_RAW; ++ const u8 *src = data + chunk * 4096; ++ ++ memcpy(dst, src, H2FMI_CHUNK_LO); ++ memcpy(dst + H2FMI_CHUNK_LO, oob + chunk * H2FMI_CHUNK_META, ++ H2FMI_CHUNK_META); ++ memcpy(dst + H2FMI_CHUNK_LO + H2FMI_CHUNK_META, ++ src + H2FMI_CHUNK_LO, H2FMI_CHUNK_HI); ++ } ++} ++ ++/* Canonical data and metadata form one complete PPN program unit. */ ++static int h2fmi_program_page(struct h2fmi *fmi, struct h2fmi_bus *b, ++ unsigned int cau, unsigned int block, ++ unsigned int page, const u8 *data, const u8 *oob) ++{ ++ u32 row; ++ u8 status, *expected; ++ int ret; ++ ++ if (!IS_ENABLED(CONFIG_MTD_APPLE_H2FMI_WRITE)) ++ return -EROFS; ++ if (!data || !oob) ++ return -EINVAL; ++ ret = h2fmi_page_row(&b->geometry, cau, block, page, &row); ++ if (ret) ++ return ret; ++ /* Preserve the boot block and both package-reserved tail blocks. */ ++ if (!block || block >= b->geometry.blocks - 2) ++ return -EPERM; ++ expected = kmalloc(H2FMI_PAGE_RAW, GFP_KERNEL); ++ if (!expected) ++ return -ENOMEM; ++ h2fmi_join_page(expected, data, oob); ++ mutex_lock(&b->lock); ++ ret = h2fmi_read_transaction(fmi, b, 0x0a, 0x37, row, 4, ++ b->page, H2FMI_PAGE_RAW, &status); ++ if (ret) ++ goto out; ++ if (status != PPN_ST_ERASED || ++ memchr_inv(b->page, 0xff, H2FMI_PAGE_RAW)) { ++ ret = -EEXIST; ++ goto out; ++ } ++ ret = h2fmi_program_transaction(fmi, b, row, expected); ++ if (ret) ++ goto out; ++ ret = h2fmi_read_transaction(fmi, b, 0x0a, 0x37, row, 4, ++ b->page, H2FMI_PAGE_RAW, &status); ++ if (ret || status != PPN_ST_GOOD || ++ memcmp(b->page, expected, H2FMI_PAGE_RAW)) { ++ b->failed = true; ++ ret = ret ? ret : -EIO; ++ dev_err(fmi->dev, "PPN readback row %08x: status %02x, error %d\n", ++ row, status, ret); ++ goto out; ++ } ++out: ++ mutex_unlock(&b->lock); ++ kfree(expected); ++ return ret; ++} ++ ++/* Publish MTD progress after the whole data/OOB unit passes readback. */ ++static int h2fmi_mtd_write_oob(struct mtd_info *mtd, loff_t to, ++ struct mtd_oob_ops *ops) ++{ ++ struct h2fmi_die *die = mtd->priv; ++ struct h2fmi *fmi = die->fmi; ++ struct h2fmi_bus *b = &fmi->bus[die->bus]; ++ u64 page = (u64)to >> H2FMI_PAGE_SHIFT; ++ u32 block = page >> b->geometry.page_bits; ++ u32 pg = page & (b->geometry.pages - 1); ++ int ret; ++ ++ ops->retlen = 0; ++ ops->oobretlen = 0; ++ if (!IS_ENABLED(CONFIG_MTD_APPLE_H2FMI_WRITE)) ++ return -EROFS; ++ if (to < 0 || to >= mtd->size || to & H2FMI_PAGE_MASK || ++ ops->mode != MTD_OPS_PLACE_OOB || ops->len != H2FMI_PAGE_DATA || ++ ops->ooblen != H2FMI_PAGE_OOB || ops->ooboffs || ++ !ops->datbuf || !ops->oobbuf) ++ return -EINVAL; ++ ret = h2fmi_program_page(fmi, b, die->cau, block, pg, ++ ops->datbuf, ops->oobbuf); ++ if (!ret) { ++ ops->retlen = H2FMI_PAGE_DATA; ++ ops->oobretlen = H2FMI_PAGE_OOB; ++ } ++ return ret; ++} ++ + static int h2fmi_read_page_at(struct mtd_info *mtd, loff_t from, + u8 *data, u8 *oob, u8 *stp) + { +@@ -554,6 +787,10 @@ static int h2fmi_setup_mtd(struct h2fmi *fmi, int idx) + mtd->type = MTD_NANDFLASH; + /* PPN page reads expose the package metadata through MTD OOB. */ + mtd->flags = MTD_NO_ERASE; ++ if (IS_ENABLED(CONFIG_MTD_APPLE_H2FMI_WRITE)) { ++ mtd->flags |= MTD_WRITEABLE; ++ mtd->_write_oob = h2fmi_mtd_write_oob; ++ } + mtd->erasesize = g->pages * H2FMI_PAGE_DATA; + mtd->size = (u64)g->blocks * mtd->erasesize; + mtd->writesize = H2FMI_PAGE_DATA; +diff --git a/drivers/mtd/devices/mtd-apple-h2fmi.h b/drivers/mtd/devices/mtd-apple-h2fmi.h +index ad573a5c0..aeb812f33 100644 +--- a/drivers/mtd/devices/mtd-apple-h2fmi.h ++++ b/drivers/mtd/devices/mtd-apple-h2fmi.h +@@ -2,6 +2,7 @@ + #ifndef MTD_APPLE_H2FMI_H + #define MTD_APPLE_H2FMI_H + ++#include + #include + #include + #include +@@ -119,4 +120,10 @@ static inline int h2fmi_page_status(u8 status) + } + } + ++/* CMD 79 reports ready at bit 6 and program failure at bits 0 and 4. */ ++static inline int h2fmi_program_status(u8 status) ++{ ++ return (status & BIT(6)) && !(status & (BIT(0) | BIT(4))) ? 0 : -EIO; ++} ++ + #endif diff --git a/kernel/patches/drivers/mtd/0002-mtd-apple-h2fmi-Add-verified-complete-page-programmi.patch.toml b/kernel/patches/drivers/mtd/0002-mtd-apple-h2fmi-Add-verified-complete-page-programmi.patch.toml new file mode 100644 index 00000000..1248e2bf --- /dev/null +++ b/kernel/patches/drivers/mtd/0002-mtd-apple-h2fmi-Add-verified-complete-page-programmi.patch.toml @@ -0,0 +1,2 @@ +schema = 1 +platforms = ["apple-armv7"] diff --git a/kernel/patches/drivers/mtd/0003-mtd-apple-h2fmi-provide-canonical-page-reads-to-SFTL.patch b/kernel/patches/drivers/mtd/0003-mtd-apple-h2fmi-provide-canonical-page-reads-to-SFTL.patch new file mode 100644 index 00000000..2174d0d2 --- /dev/null +++ b/kernel/patches/drivers/mtd/0003-mtd-apple-h2fmi-provide-canonical-page-reads-to-SFTL.patch @@ -0,0 +1,173 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> +Date: Fri, 6 Nov 2026 11:48:07 -0600 +Subject: [PATCH] mtd: apple-h2fmi: provide canonical page reads to SFTL + +Expose normal and SLC-context page reads through one aggregate PPN provider. +Both views keep the complete controller transaction under the bus lock and +return separate data and OOB buffers. Retired pages return their status +without copying stale bytes. + +Register an SFTL child for the supported discovered 64 GB package geometry, +and remove it before releasing the controller. The provider has no write +or erase callback. + +Assisted-by: LLM +--- + drivers/mtd/devices/mtd-apple-h2fmi.c | 78 +++++++++++++++++++++++++++ + include/linux/mtd/apple-ppn.h | 29 ++++++++++ + 2 files changed, 107 insertions(+) + create mode 100644 include/linux/mtd/apple-ppn.h + +diff --git a/drivers/mtd/devices/mtd-apple-h2fmi.c b/drivers/mtd/devices/mtd-apple-h2fmi.c +index c83bc9624..3894e45a4 100644 +--- a/drivers/mtd/devices/mtd-apple-h2fmi.c ++++ b/drivers/mtd/devices/mtd-apple-h2fmi.c +@@ -32,6 +32,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -102,6 +103,7 @@ struct h2fmi { + struct mtd_info *mtd; + struct h2fmi_die *die; + unsigned int ndies; ++ struct platform_device *sftl; + }; + + /* +@@ -398,6 +400,73 @@ static int h2fmi_read_page_at(struct mtd_info *mtd, loff_t from, + return ret; + } + ++/* Canonical normal/context reads share the same complete transaction lock. */ ++static int h2fmi_ppn_read(void *priv, unsigned int bus, unsigned int cau, ++ unsigned int block, unsigned int page, bool context, ++ u8 *data, u8 *oob, u8 *status) ++{ ++ struct h2fmi *fmi = priv; ++ struct h2fmi_bus *b; ++ u32 row; ++ int ret; ++ ++ if (bus >= H2FMI_BUSES || !data || !oob || !status) ++ return -EINVAL; ++ b = &fmi->bus[bus]; ++ if (context) ++ ret = h2fmi_slc_row(&b->geometry, cau, block, page, &row); ++ else ++ ret = h2fmi_page_row(&b->geometry, cau, block, page, &row); ++ if (ret) ++ return ret; ++ ++ /* Retired pages have no data phase. Never return a stale page buffer. */ ++ memset(data, 0, H2FMI_PAGE_DATA); ++ memset(oob, 0, H2FMI_PAGE_OOB); ++ mutex_lock(&b->lock); ++ ret = h2fmi_read_transaction(fmi, b, 0x0a, 0x37, row, 4, ++ b->page, H2FMI_PAGE_RAW, status); ++ if (!ret && h2fmi_page_status(*status) >= 0) { ++ if (*status == PPN_ST_ERASED && memchr_inv(b->page, 0xff, H2FMI_PAGE_RAW)) ++ ret = -EIO; ++ else ++ h2fmi_split_page(b->page, data, oob); ++ } else if (!ret && *status != PPN_ST_RETIRED) { ++ ret = -EIO; ++ } ++ mutex_unlock(&b->lock); ++ return ret; ++} ++ ++static const struct apple_ppn_ops h2fmi_ppn_ops = { ++ .read_page = h2fmi_ppn_read, ++}; ++ ++static int h2fmi_register_sftl(struct h2fmi *fmi) ++{ ++ const struct h2fmi_geometry *g = &fmi->bus[0].geometry; ++ struct apple_ppn provider = { ++ .dev = fmi->dev, ++ .ops = &h2fmi_ppn_ops, ++ .priv = fmi, ++ .buses = H2FMI_BUSES, ++ .caus = g->caus, ++ .blocks = g->blocks, ++ .pages = g->pages, ++ }; ++ ++ /* This recovery implementation supports the measured 64 GB profile. */ ++ if (!IS_ENABLED(CONFIG_MTD_APPLE_SFTL) || g->caus != 8 || ++ g->blocks != 2088 || g->pages != 128 || g->slc_pages != 64) ++ return 0; ++ fmi->sftl = platform_device_register_data(fmi->dev, "apple-sftl", ++ PLATFORM_DEVID_AUTO, ++ &provider, sizeof(provider)); ++ if (IS_ERR(fmi->sftl)) ++ return PTR_ERR(fmi->sftl); ++ return 0; ++} ++ + /* + * Turn the PPN's status byte into what MTD expects back from _read_oob: a + * count of corrected bitflips, or a negative errno. +@@ -641,6 +710,13 @@ static int h2fmi_probe(struct platform_device *pdev) + } + } + ++ ret = h2fmi_register_sftl(fmi); ++ if (ret) { ++ for (i = 0; i < fmi->ndies; i++) ++ mtd_device_unregister(&fmi->mtd[i]); ++ return ret; ++ } ++ + platform_set_drvdata(pdev, fmi); + dev_info(&pdev->dev, "%u CAUs, %u blocks of %u KiB each\n", + fmi->ndies, fmi->bus[0].geometry.blocks, +@@ -653,6 +729,8 @@ static void h2fmi_remove(struct platform_device *pdev) + struct h2fmi *fmi = platform_get_drvdata(pdev); + int i; + ++ if (fmi->sftl) ++ platform_device_unregister(fmi->sftl); + for (i = 0; i < fmi->ndies; i++) + mtd_device_unregister(&fmi->mtd[i]); + } +diff --git a/include/linux/mtd/apple-ppn.h b/include/linux/mtd/apple-ppn.h +new file mode 100644 +index 000000000..78ee66867 +--- /dev/null ++++ b/include/linux/mtd/apple-ppn.h +@@ -0,0 +1,29 @@ ++/* SPDX-License-Identifier: GPL-2.0-only */ ++#ifndef _LINUX_MTD_APPLE_PPN_H ++#define _LINUX_MTD_APPLE_PPN_H ++ ++#include ++ ++struct device; ++ ++#define APPLE_PPN_PAGE_SIZE 16384 ++#define APPLE_PPN_OOB_SIZE 64 ++ ++/* All buffers have canonical data/OOB layout, including context reads. */ ++struct apple_ppn_ops { ++ int (*read_page)(void *priv, unsigned int bus, unsigned int cau, ++ unsigned int block, unsigned int page, bool context, ++ u8 *data, u8 *oob, u8 *status); ++}; ++ ++struct apple_ppn { ++ struct device *dev; ++ const struct apple_ppn_ops *ops; ++ void *priv; ++ u16 buses; ++ u16 caus; ++ u16 blocks; ++ u16 pages; ++}; ++ ++#endif diff --git a/kernel/patches/drivers/mtd/0003-mtd-apple-h2fmi-provide-canonical-page-reads-to-SFTL.patch.toml b/kernel/patches/drivers/mtd/0003-mtd-apple-h2fmi-provide-canonical-page-reads-to-SFTL.patch.toml new file mode 100644 index 00000000..1248e2bf --- /dev/null +++ b/kernel/patches/drivers/mtd/0003-mtd-apple-h2fmi-provide-canonical-page-reads-to-SFTL.patch.toml @@ -0,0 +1,2 @@ +schema = 1 +platforms = ["apple-armv7"] diff --git a/kernel/patches/drivers/mtd/0004-mtd-add-read-only-Apple-VFL-and-SwissPPNFTL-recovery.patch b/kernel/patches/drivers/mtd/0004-mtd-add-read-only-Apple-VFL-and-SwissPPNFTL-recovery.patch new file mode 100644 index 00000000..bde46080 --- /dev/null +++ b/kernel/patches/drivers/mtd/0004-mtd-add-read-only-Apple-VFL-and-SwissPPNFTL-recovery.patch @@ -0,0 +1,1835 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> +Date: Fri, 6 Nov 2026 11:48:19 -0600 +Subject: [PATCH] mtd: add read-only Apple VFL and SwissPPNFTL recovery + +Recover the measured 64 GB PPN layout from replicated VFL contexts and +lane tables. Decode the saved SFTL ranges and replay the subsequent native +journal, including completed-block summaries and open stripes with holes. +Reject unsupported formats and incomplete sequences before publishing a disk. + +Expose the 64,000,000,000-byte logical medium as a read-only block device. +The map retains sixteen internal keys outside the disk capacity. Requests +use a preallocated buffer, and a failed storage read stops further reads. + +Port the format rules from Orchard's P105 Python reference. Add synthetic +complete-medium tests for checkpoint recovery, journal and trim ordering, +replica disagreement, torn summaries and runtime failure. The fixtures +contain no captured firmware or device bytes. + +Assisted-by: LLM +--- + Documentation/driver-api/mtd/apple-sftl.rst | 61 ++ + Documentation/driver-api/mtd/index.rst | 2 +- + drivers/mtd/Kconfig | 21 + + drivers/mtd/Makefile | 4 + + drivers/mtd/apple-sftl-block.c | 234 +++++ + drivers/mtd/apple-sftl-core.c | 949 ++++++++++++++++++ + drivers/mtd/apple-sftl-test.c | 436 ++++++++ + drivers/mtd/apple-sftl.h | 23 + + tools/testing/kunit/configs/apple_sftl.config | 4 + + 9 files changed, 1733 insertions(+), 1 deletion(-) + create mode 100644 Documentation/driver-api/mtd/apple-sftl.rst + create mode 100644 drivers/mtd/apple-sftl-block.c + create mode 100644 drivers/mtd/apple-sftl-core.c + create mode 100644 drivers/mtd/apple-sftl-test.c + create mode 100644 drivers/mtd/apple-sftl.h + create mode 100644 tools/testing/kunit/configs/apple_sftl.config + +diff --git a/Documentation/driver-api/mtd/apple-sftl.rst b/Documentation/driver-api/mtd/apple-sftl.rst +new file mode 100644 +index 000000000..8295a5a2a +--- /dev/null ++++ b/Documentation/driver-api/mtd/apple-sftl.rst +@@ -0,0 +1,61 @@ ++.. SPDX-License-Identifier: GPL-2.0-only ++ ++Apple PPN VFL/SwissPPNFTL reader ++============================== ++ ++The ``apple-sftl`` driver recovers the logical medium above Apple's PPN flash ++mapping. H2FMI supplies complete 16 KiB pages and their 64 metadata bytes ++through an aggregate provider. Context reads use the package's separate SLC ++address view. The controller serializes each complete transaction. ++ ++Supported format ++---------------- ++ ++This implementation supports the measured 64 GB Hynix PPN profile with two ++buses and eight CAUs per bus. Each CAU has 2,088 blocks of 128 pages. Profile ++selection uses discovered geometry. Other layouts return ``-EOPNOTSUPP``. ++ ++Recovery selects four agreeing VFL checkpoint copies and checks each ++replicated lane table. A VFL superblock has an 8,192-unit address stride; ++units are 4 KiB. Blocks with fewer active lanes have an unused address gap ++which the reader rejects. ++ ++SFTL subtype-4 context fragments describe the saved logical ranges. Recovery ++validates the complete context sequence, then applies later data and trim ++records in sequence order. Completed blocks carry summaries at their ends. ++Open blocks are scanned completely because an interrupted stripe may leave ++an erased lane before another completed lane. Summary allocation spans also ++preserve the next sequence floor when it exceeds the last visible record. ++ ++The map has 15,625,016 entries, including sixteen internal keys. The block ++device exposes 15,625,000 entries, or 64,000,000,000 bytes, with 512-byte sector ++reads. Linux's disk name is ``applesftlN``. LwVM and filesystem interpretation ++happen above this logical provider. Encrypted partition contents remain ++ciphertext. ++ ++Failure and lifetime rules ++-------------------------- ++ ++The disk is read-only and the provider has no programming or erase operation. ++Malformed context copies and incomplete journal sequences prevent disk ++registration. A refresh status requires a writer and is reported as an error. ++A failed runtime read stops subsequent reads from this map instance. ++ ++H2FMI unregisters the child before releasing its controller state. The block ++frontend freezes and drains requests during removal. Its request buffer is ++allocated before the disk becomes visible, while blocking requests serialize ++access to that buffer and the provider. ++ ++Tests ++----- ++ ++``tools/testing/kunit/configs/apple_sftl.config`` enables the synthetic medium ++tests. They generate VFL replicas and lane tables, plus an SFTL checkpoint ++followed by completed and open data blocks. Tests cover trim ordering and ++allocation floors. Corrupt replicas and sequence gaps must return an error ++with no disk or usable map. Test inputs contain no captured device bytes. ++ ++The format rules were ported from Orchard's P105 Python reference ++(``ftl_map.py``, ``ftl_load.py`` and ``ftl_log.py``), which was checked against ++native iOS logical reads. A kernel object compile or synthetic test result ++does not establish physical-controller timing or qualify a new device layout. +diff --git a/Documentation/driver-api/mtd/index.rst b/Documentation/driver-api/mtd/index.rst +index 6a4278f40..e91da2aa5 100644 +--- a/Documentation/driver-api/mtd/index.rst ++++ b/Documentation/driver-api/mtd/index.rst +@@ -9,4 +9,4 @@ Memory Technology Device (MTD) + + spi-intel + nand_ecc +- spi-nor ++ spi-nor apple-sftl +diff --git a/drivers/mtd/Kconfig b/drivers/mtd/Kconfig +index 796a2eccb..155136a36 100644 +--- a/drivers/mtd/Kconfig ++++ b/drivers/mtd/Kconfig +@@ -206,6 +206,27 @@ config MTD_PARTITIONED_MASTER + the parent of the partition device be the master device, rather than + what lies behind the master. + ++config MTD_APPLE_SFTL ++ tristate "Apple PPN read-only VFL/SwissPPNFTL translation" ++ depends on BLOCK ++ help ++ Recover the logical block medium stored in Apple VFL/SwissPPNFTL ++ metadata on the measured 64 GB Hynix PPN layout. The H2FMI driver ++ supplies canonical page reads through its aggregate PPN provider. ++ Unsupported checkpoints, missing journal sequences and refresh ++ requirements stop recovery before a disk is registered. ++ ++ The disk is read-only. This driver performs no program or erase. ++ ++config MTD_APPLE_SFTL_KUNIT_TEST ++ tristate "KUnit tests for Apple VFL/SwissPPNFTL recovery" if !KUNIT_ALL_TESTS ++ depends on KUNIT && MTD_APPLE_SFTL ++ default KUNIT_ALL_TESTS ++ help ++ Synthesize complete VFL/SFTL media and exercise checkpoint recovery, ++ native block summaries and open-block journal replay. Malformed ++ replicas and unsupported layouts must fail without storage writes. ++ + source "drivers/mtd/chips/Kconfig" + + source "drivers/mtd/maps/Kconfig" +diff --git a/drivers/mtd/Makefile b/drivers/mtd/Makefile +index 593d0593a..fa6180a62 100644 +--- a/drivers/mtd/Makefile ++++ b/drivers/mtd/Makefile +@@ -31,3 +31,7 @@ obj-y += chips/ lpddr/ maps/ devices/ nand/ tests/ + obj-$(CONFIG_MTD_SPI_NOR) += spi-nor/ + obj-$(CONFIG_MTD_UBI) += ubi/ + obj-$(CONFIG_MTD_HYPERBUS) += hyperbus/ ++ ++obj-$(CONFIG_MTD_APPLE_SFTL) += apple-sftl.o ++apple-sftl-y := apple-sftl-core.o apple-sftl-block.o ++obj-$(CONFIG_MTD_APPLE_SFTL_KUNIT_TEST) += apple-sftl-test.o +diff --git a/drivers/mtd/apple-sftl-block.c b/drivers/mtd/apple-sftl-block.c +new file mode 100644 +index 000000000..86d02921b +--- /dev/null ++++ b/drivers/mtd/apple-sftl-block.c +@@ -0,0 +1,234 @@ ++// SPDX-License-Identifier: GPL-2.0-only ++/* Read-only block frontend for Apple's VFL/SwissPPNFTL logical medium. */ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include "apple-sftl.h" ++ ++#define SFTL_BLOCK_NAME "apple-sftl" ++#define SFTL_DISK_MINORS 16 ++#define SFTL_LOGICAL_BYTES 4096U ++ ++struct sftl_disk { ++ struct apple_sftl *ftl; ++ struct blk_mq_tag_set tags; ++ struct gendisk *disk; ++ struct mutex io_lock; /* serializes requests using buffer */ ++ u8 *buffer; ++ unsigned int id; ++ bool dead; ++}; ++ ++static DEFINE_IDA(sftl_disk_ids); ++static int sftl_major; ++ ++/* The blocking queue and this lock keep the preallocated buffer exclusive. */ ++static int sftl_read_request(struct sftl_disk *sftl, struct request *request) ++{ ++ struct req_iterator iterator; ++ struct bio_vec bvec; ++ u64 position = (u64)blk_rq_pos(request) << SECTOR_SHIFT; ++ u32 cached = U32_MAX; ++ ++ rq_for_each_segment(bvec, request, iterator) { ++ unsigned int done = 0; ++ ++ while (done < bvec.bv_len) { ++ u32 logical = position >> 12; ++ unsigned int offset = position & (SFTL_LOGICAL_BYTES - 1); ++ unsigned int length = min(bvec.bv_len - done, ++ SFTL_LOGICAL_BYTES - offset); ++ void *mapped; ++ int ret; ++ ++ if (logical != cached) { ++ ret = apple_sftl_read(sftl->ftl, logical, sftl->buffer); ++ if (ret) ++ return ret; ++ cached = logical; ++ } ++ mapped = kmap_local_page(bvec.bv_page); ++ memcpy(mapped + bvec.bv_offset + done, ++ sftl->buffer + offset, length); ++ kunmap_local(mapped); ++ done += length; ++ position += length; ++ } ++ } ++ return 0; ++} ++ ++static blk_status_t sftl_queue_rq(struct blk_mq_hw_ctx *hctx, ++ const struct blk_mq_queue_data *data) ++{ ++ struct request *request = data->rq; ++ struct sftl_disk *sftl = request->q->queuedata; ++ sector_t capacity = get_capacity(sftl->disk); ++ blk_status_t status; ++ int ret = 0; ++ ++ blk_mq_start_request(request); ++ mutex_lock(&sftl->io_lock); ++ if (sftl->dead) { ++ ret = -ENODEV; ++ } else if (req_op(request) == REQ_OP_FLUSH) { ++ /* A read-only device has no buffered writes to flush. */ ++ } else if (req_op(request) != REQ_OP_READ) { ++ ret = -EOPNOTSUPP; ++ } else if (blk_rq_pos(request) > capacity || ++ blk_rq_sectors(request) > capacity - blk_rq_pos(request)) { ++ ret = -EIO; ++ } else { ++ ret = sftl_read_request(sftl, request); ++ } ++ mutex_unlock(&sftl->io_lock); ++ status = errno_to_blk_status(ret); ++ blk_mq_end_request(request, status); ++ return BLK_STS_OK; ++} ++ ++static const struct blk_mq_ops sftl_mq_ops = { ++ .queue_rq = sftl_queue_rq, ++}; ++ ++static const struct block_device_operations sftl_block_ops = { ++ .owner = THIS_MODULE, ++}; ++ ++static int sftl_disk_probe(struct platform_device *pdev) ++{ ++ struct queue_limits limits = { ++ .logical_block_size = SECTOR_SIZE, ++ .physical_block_size = SFTL_LOGICAL_BYTES, ++ .max_hw_sectors = 128, ++ }; ++ struct apple_ppn *provider = dev_get_platdata(&pdev->dev); ++ struct apple_sftl_info info; ++ struct sftl_disk *sftl; ++ struct gendisk *disk; ++ int ret, id; ++ ++ if (!provider) ++ return -EINVAL; ++ sftl = kzalloc(sizeof(*sftl), GFP_KERNEL); ++ if (!sftl) ++ return -ENOMEM; ++ mutex_init(&sftl->io_lock); ++ sftl->buffer = kmalloc(SFTL_LOGICAL_BYTES, GFP_KERNEL); ++ if (!sftl->buffer) { ++ ret = -ENOMEM; ++ goto free_state; ++ } ++ ret = apple_sftl_load(provider, &sftl->ftl); ++ if (ret) ++ goto free_buffer; ++ apple_sftl_get_info(sftl->ftl, &info); ++ if (!info.disk_pages || info.disk_pages > info.logical_pages) { ++ ret = -EINVAL; ++ goto free_ftl; ++ } ++ id = ida_alloc_range(&sftl_disk_ids, 0, MINORMASK / SFTL_DISK_MINORS, ++ GFP_KERNEL); ++ if (id < 0) { ++ ret = id; ++ goto free_ftl; ++ } ++ sftl->id = id; ++ ret = blk_mq_alloc_sq_tag_set(&sftl->tags, &sftl_mq_ops, 8, ++ BLK_MQ_F_SHOULD_MERGE | BLK_MQ_F_BLOCKING); ++ if (ret) ++ goto free_id; ++ disk = blk_mq_alloc_disk(&sftl->tags, &limits, sftl); ++ if (IS_ERR(disk)) { ++ ret = PTR_ERR(disk); ++ goto free_tags; ++ } ++ sftl->disk = disk; ++ disk->major = sftl_major; ++ disk->first_minor = id * SFTL_DISK_MINORS; ++ disk->minors = SFTL_DISK_MINORS; ++ disk->fops = &sftl_block_ops; ++ disk->private_data = sftl; ++ snprintf(disk->disk_name, DISK_NAME_LEN, "applesftl%d", id); ++ set_capacity(disk, (sector_t)info.disk_pages * (SFTL_LOGICAL_BYTES / SECTOR_SIZE)); ++ set_disk_ro(disk, true); ++ platform_set_drvdata(pdev, sftl); ++ ret = device_add_disk(&pdev->dev, disk, NULL); ++ if (ret) ++ goto put_disk; ++ dev_info(&pdev->dev, "%s: read-only, %u logical pages, sequence %llu\n", ++ disk->disk_name, info.disk_pages, info.sequence); ++ return 0; ++ ++put_disk: ++ platform_set_drvdata(pdev, NULL); ++ put_disk(disk); ++free_tags: ++ blk_mq_free_tag_set(&sftl->tags); ++free_id: ++ ida_free(&sftl_disk_ids, sftl->id); ++free_ftl: ++ apple_sftl_free(sftl->ftl); ++free_buffer: ++ kfree(sftl->buffer); ++free_state: ++ kfree(sftl); ++ return ret; ++} ++ ++static void sftl_disk_remove(struct platform_device *pdev) ++{ ++ struct sftl_disk *sftl = platform_get_drvdata(pdev); ++ ++ mutex_lock(&sftl->io_lock); ++ sftl->dead = true; ++ mutex_unlock(&sftl->io_lock); ++ /* del_gendisk freezes and drains the queue before the provider is freed. */ ++ del_gendisk(sftl->disk); ++ put_disk(sftl->disk); ++ blk_mq_free_tag_set(&sftl->tags); ++ ida_free(&sftl_disk_ids, sftl->id); ++ apple_sftl_free(sftl->ftl); ++ kfree(sftl->buffer); ++ kfree(sftl); ++} ++ ++static struct platform_driver sftl_driver = { ++ .probe = sftl_disk_probe, ++ .remove = sftl_disk_remove, ++ .driver = { ++ .name = SFTL_BLOCK_NAME, ++ }, ++}; ++ ++static int __init sftl_init(void) ++{ ++ int ret; ++ ++ sftl_major = register_blkdev(0, SFTL_BLOCK_NAME); ++ if (sftl_major < 0) ++ return sftl_major; ++ ret = platform_driver_register(&sftl_driver); ++ if (ret) ++ unregister_blkdev(sftl_major, SFTL_BLOCK_NAME); ++ return ret; ++} ++module_init(sftl_init); ++ ++static void __exit sftl_exit(void) ++{ ++ platform_driver_unregister(&sftl_driver); ++ unregister_blkdev(sftl_major, SFTL_BLOCK_NAME); ++ ida_destroy(&sftl_disk_ids); ++} ++module_exit(sftl_exit); ++ ++MODULE_DESCRIPTION("Apple VFL/SwissPPNFTL read-only block driver"); ++MODULE_LICENSE("GPL"); ++MODULE_ALIAS("platform:" SFTL_BLOCK_NAME); +diff --git a/drivers/mtd/apple-sftl-core.c b/drivers/mtd/apple-sftl-core.c +new file mode 100644 +index 000000000..ba90916f2 +--- /dev/null ++++ b/drivers/mtd/apple-sftl-core.c +@@ -0,0 +1,949 @@ ++// SPDX-License-Identifier: GPL-2.0-only ++/* ++ * Apple VFL/SwissPPNFTL read-only recovery for the measured 64 GB PPN layout. ++ * ++ * Format and replay rules are ported from Orchard's P105 Python reference ++ * (ftl_map.py, ftl_load.py and ftl_log.py). That implementation was checked ++ * against native iOS logical reads. This code contains no captured firmware ++ * or device data. Unsupported formats fail before a block device is created. ++ */ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include "apple-sftl.h" ++ ++#define SFTL_LPAGES 15625016U ++#define SFTL_DISK_PAGES 15625000U ++#define SFTL_BLOCKS 2088U ++#define SFTL_SUBBLOCKS 2086U ++#define SFTL_LANES 16U ++#define SFTL_STRIDE 8192U ++#define SFTL_PROVIDER (SFTL_SUBBLOCKS * SFTL_STRIDE) ++#define SFTL_UNMAPPED U32_MAX ++#define SFTL_SEQUENCE GENMASK_ULL(47, 0) ++#define SFTL_MAX_CONTEXTS 16U ++#define SFTL_META 16U ++#define SFTL_CHUNK 4096U ++#define SFTL_TRIM 0xffff0001U ++ ++struct sftl_run { ++ struct rb_node node; ++ u32 start, end, provider; ++}; ++ ++/* stamp packs the native 48-bit sequence with its flags and record type. */ ++struct sftl_record { ++ u64 stamp; ++ u32 key, address; ++}; ++ ++struct sftl_records { ++ struct sftl_record *items; ++ u32 count, capacity; ++}; ++ ++struct sftl_member { ++ u16 block; ++ u8 lane; ++}; ++ ++struct apple_sftl { ++ struct apple_ppn *ppn; ++ struct rb_root runs; ++ u32 nr_runs; ++ u16 lanes[SFTL_BLOCKS]; ++ DECLARE_BITMAP(reserved, SFTL_BLOCKS); ++ u64 checkpoint_sequence, sequence; ++ u8 *page; ++ u8 oob[APPLE_PPN_OOB_SIZE]; ++ struct mutex lock; /* serializes reads and the shared page buffer */ ++ bool failed; ++}; ++ ++static u64 sftl_sequence(const u8 *meta) ++{ ++ return get_unaligned_le64(meta) >> 16; ++} ++ ++static u64 sftl_stamp(const u8 *meta) ++{ ++ return sftl_sequence(meta) | (u64)meta[1] << 48 | (u64)meta[0] << 56; ++} ++ ++static unsigned int sftl_kind(const struct sftl_record *record) ++{ ++ return record->stamp >> 56; ++} ++ ++static unsigned int sftl_flags(const struct sftl_record *record) ++{ ++ return (record->stamp >> 48) & 0xff; ++} ++ ++static bool sftl_erased(const void *data, size_t size) ++{ ++ return !memchr_inv(data, 0xff, size); ++} ++ ++static int sftl_read_page(struct apple_sftl *sftl, unsigned int bus, ++ unsigned int cau, unsigned int block, ++ unsigned int page, bool context, u8 *data, u8 *oob) ++{ ++ u8 status; ++ int ret; ++ ++ ret = sftl->ppn->ops->read_page(sftl->ppn->priv, bus, cau, block, ++ page, context, data, oob, &status); ++ if (ret) ++ return ret; ++ /* Refresh requires a writer. A reader must not hide that requirement. */ ++ if (status != 0x40 && status != 0x49) ++ return -EIO; ++ if (status == 0x49 && (!sftl_erased(data, APPLE_PPN_PAGE_SIZE) || ++ !sftl_erased(oob, APPLE_PPN_OOB_SIZE))) ++ return -EBADMSG; ++ return 0; ++} ++ ++static int sftl_translate(struct apple_sftl *sftl, u32 address, ++ unsigned int *bus, unsigned int *cau, ++ unsigned int *block, unsigned int *page, ++ unsigned int *chunk) ++{ ++ u32 offset; ++ unsigned int lane, wanted, count; ++ u16 bitmap; ++ ++ if (address >= SFTL_PROVIDER) ++ return -ERANGE; ++ *chunk = address & 3; ++ *block = address / SFTL_STRIDE; ++ offset = (address % SFTL_STRIDE) / 4; ++ bitmap = sftl->lanes[*block]; ++ count = hweight16(bitmap); ++ if (!count || offset / count >= 128) ++ return -ERANGE; ++ *page = offset / count; ++ wanted = offset % count; ++ for (lane = 0; lane < SFTL_LANES; lane++) { ++ if (!(bitmap & BIT(lane))) ++ continue; ++ if (!wanted--) ++ break; ++ } ++ *bus = lane & 1; ++ *cau = lane >> 1; ++ return 0; ++} ++ ++static int sftl_read_address(struct apple_sftl *sftl, u32 address, ++ unsigned int *chunk) ++{ ++ unsigned int bus, cau, block, page; ++ int ret; ++ ++ ret = sftl_translate(sftl, address, &bus, &cau, &block, &page, chunk); ++ if (ret) ++ return ret; ++ return sftl_read_page(sftl, bus, cau, block, page, false, ++ sftl->page, sftl->oob); ++} ++ ++static struct sftl_run *sftl_find(struct apple_sftl *sftl, u32 logical) ++{ ++ struct rb_node *node = sftl->runs.rb_node; ++ ++ while (node) { ++ struct sftl_run *run = rb_entry(node, struct sftl_run, node); ++ ++ if (logical < run->start) ++ node = node->rb_left; ++ else if (logical >= run->end) ++ node = node->rb_right; ++ else ++ return run; ++ } ++ return NULL; ++} ++ ++static void sftl_insert(struct apple_sftl *sftl, struct sftl_run *run) ++{ ++ struct rb_node **link = &sftl->runs.rb_node, *parent = NULL; ++ ++ while (*link) { ++ struct sftl_run *entry = rb_entry(*link, struct sftl_run, node); ++ ++ parent = *link; ++ link = run->start < entry->start ? &parent->rb_left : ++ &parent->rb_right; ++ } ++ rb_link_node(&run->node, parent, link); ++ rb_insert_color(&run->node, &sftl->runs); ++ sftl->nr_runs++; ++} ++ ++static int sftl_add_run(struct apple_sftl *sftl, u32 start, u32 end, u32 provider) ++{ ++ struct sftl_run *run; ++ ++ if (start >= end || end > SFTL_LPAGES || ++ (provider != SFTL_UNMAPPED && ++ (provider >= SFTL_PROVIDER || end - start > SFTL_PROVIDER - provider))) ++ return -EBADMSG; ++ run = kmalloc(sizeof(*run), GFP_KERNEL); ++ if (!run) ++ return -ENOMEM; ++ run->start = start; ++ run->end = end; ++ run->provider = provider; ++ sftl_insert(sftl, run); ++ return 0; ++} ++ ++static int sftl_split(struct apple_sftl *sftl, u32 point) ++{ ++ struct sftl_run *left = sftl_find(sftl, point), *right; ++ ++ if (!left) ++ return -EBADMSG; ++ if (left->start == point) ++ return 0; ++ right = kmalloc(sizeof(*right), GFP_KERNEL); ++ if (!right) ++ return -ENOMEM; ++ right->start = point; ++ right->end = left->end; ++ right->provider = left->provider == SFTL_UNMAPPED ? SFTL_UNMAPPED : ++ left->provider + point - left->start; ++ left->end = point; ++ sftl_insert(sftl, right); ++ return 0; ++} ++ ++static bool sftl_contiguous(struct sftl_run *left, struct sftl_run *right) ++{ ++ return left->end == right->start && ++ ((left->provider == SFTL_UNMAPPED && right->provider == SFTL_UNMAPPED) || ++ (left->provider != SFTL_UNMAPPED && right->provider != SFTL_UNMAPPED && ++ left->provider + left->end - left->start == right->provider)); ++} ++ ++static void sftl_merge(struct apple_sftl *sftl, struct sftl_run *run) ++{ ++ struct rb_node *node = rb_prev(&run->node); ++ struct sftl_run *neighbor; ++ ++ if (node) { ++ neighbor = rb_entry(node, struct sftl_run, node); ++ if (sftl_contiguous(neighbor, run)) { ++ neighbor->end = run->end; ++ rb_erase(&run->node, &sftl->runs); ++ kfree(run); ++ sftl->nr_runs--; ++ run = neighbor; ++ } ++ } ++ node = rb_next(&run->node); ++ if (!node) ++ return; ++ neighbor = rb_entry(node, struct sftl_run, node); ++ if (sftl_contiguous(run, neighbor)) { ++ run->end = neighbor->end; ++ rb_erase(node, &sftl->runs); ++ kfree(neighbor); ++ sftl->nr_runs--; ++ } ++} ++ ++static int sftl_replace(struct apple_sftl *sftl, u32 start, u32 length, u32 address) ++{ ++ struct sftl_run *run, *new; ++ struct rb_node *node, *next; ++ u32 end; ++ int ret; ++ ++ if (!length || start >= SFTL_LPAGES || length > SFTL_LPAGES - start || ++ (address != SFTL_UNMAPPED && ++ (address >= SFTL_PROVIDER || length > SFTL_PROVIDER - address))) ++ return -EBADMSG; ++ end = start + length; ++ ret = sftl_split(sftl, start); ++ if (ret) ++ return ret; ++ if (end < SFTL_LPAGES) { ++ ret = sftl_split(sftl, end); ++ if (ret) ++ return ret; ++ } ++ new = kmalloc(sizeof(*new), GFP_KERNEL); ++ if (!new) ++ return -ENOMEM; ++ run = sftl_find(sftl, start); ++ for (node = &run->node; node; node = next) { ++ run = rb_entry(node, struct sftl_run, node); ++ if (run->start >= end) ++ break; ++ next = rb_next(node); ++ rb_erase(node, &sftl->runs); ++ kfree(run); ++ sftl->nr_runs--; ++ } ++ new->start = start; ++ new->end = end; ++ new->provider = address; ++ sftl_insert(sftl, new); ++ sftl_merge(sftl, new); ++ return 0; ++} ++ ++static int sftl_record_add(struct sftl_records *records, u64 stamp, ++ u32 key, u32 address) ++{ ++ struct sftl_record *new; ++ u32 capacity; ++ ++ if (records->count == SFTL_PROVIDER) ++ return -E2BIG; ++ if (records->count == records->capacity) { ++ capacity = min_t(u32, max(records->capacity * 2, 256U), SFTL_PROVIDER); ++ new = kvrealloc(records->items, array_size(capacity, sizeof(*new)), ++ GFP_KERNEL); ++ if (!new) ++ return -ENOMEM; ++ records->items = new; ++ records->capacity = capacity; ++ } ++ records->items[records->count++] = (struct sftl_record) { ++ .stamp = stamp, .key = key, .address = address, ++ }; ++ return 0; ++} ++ ++static int sftl_record_compare(const void *a, const void *b) ++{ ++ u64 left = ((const struct sftl_record *)a)->stamp & SFTL_SEQUENCE; ++ u64 right = ((const struct sftl_record *)b)->stamp & SFTL_SEQUENCE; ++ ++ return left < right ? -1 : left > right; ++} ++ ++static int sftl_vfl(struct apple_sftl *sftl) ++{ ++ struct sftl_member members[8]; ++ u8 list[32], best_oob[64], best_members = 0; ++ u8 *best, *table; ++ u32 best_age = U32_MAX; ++ unsigned int member, page, latest_page = 0, lane, block; ++ int ret; ++ ++ best = kmalloc(APPLE_PPN_PAGE_SIZE, GFP_KERNEL); ++ table = kmalloc(APPLE_PPN_PAGE_SIZE + APPLE_PPN_OOB_SIZE, GFP_KERNEL); ++ if (!best || !table) { ++ ret = -ENOMEM; ++ goto out; ++ } ++ ret = sftl_read_page(sftl, 0, 0, 2087, 0, true, sftl->page, sftl->oob); ++ if (ret) ++ goto out; ++ if (get_unaligned_le32(sftl->oob) != 0x00ffff20) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ memcpy(list, sftl->page + 0x100, sizeof(list)); ++ for (member = 0; member < 8; member++) { ++ unsigned int other; ++ ++ members[member].block = get_unaligned_le16(list + member * 4); ++ members[member].lane = list[member * 4 + 2]; ++ if (members[member].block < 2056 || members[member].block >= SFTL_BLOCKS || ++ members[member].lane >= SFTL_LANES || list[member * 4 + 3]) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ for (other = 0; other < member; other++) ++ if (members[other].block == members[member].block && ++ members[other].lane == members[member].lane) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ } ++ for (member = 0; member < 8; member++) { ++ for (page = 0; page < 64; page++) { ++ u32 age; ++ ++ ret = sftl_read_page(sftl, members[member].lane & 1, ++ members[member].lane >> 1, members[member].block, ++ page, true, sftl->page, sftl->oob); ++ if (ret) ++ goto out; ++ if (!page && memcmp(sftl->page + 0x100, list, sizeof(list))) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ if (get_unaligned_le32(sftl->oob) != 0x00ffff20) ++ continue; ++ age = get_unaligned_le32(sftl->oob + 4); ++ if (age < BIT(31)) { ++ ret = -EOPNOTSUPP; ++ goto out; ++ } ++ if (age > best_age) ++ continue; ++ if (!best_members || age < best_age) { ++ best_age = age; ++ latest_page = page; ++ memcpy(best, sftl->page, APPLE_PPN_PAGE_SIZE); ++ memcpy(best_oob, sftl->oob, sizeof(best_oob)); ++ best_members = BIT(member); ++ } else { ++ if (page != latest_page || best_members & BIT(member) || ++ memcmp(best, sftl->page, APPLE_PPN_PAGE_SIZE) || ++ memcmp(best_oob, sftl->oob, sizeof(best_oob))) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ best_members |= BIT(member); ++ } ++ } ++ } ++ if ((best_members != 0x0f && best_members != 0xf0) || ++ get_unaligned_le16(best + 10) != 16 || ++ get_unaligned_le16(best + 12) != SFTL_SUBBLOCKS || ++ get_unaligned_le16(best + 14) != SFTL_SUBBLOCKS) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ for (lane = 0; lane < SFTL_LANES; lane++) { ++ u16 reference = get_unaligned_le16(best + 0x200 + lane * 32); ++ unsigned int group = reference >> 15; ++ ++ page = reference & 0x7fff; ++ if (page >= 64) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ for (member = group * 4; member < group * 4 + 4; member++) { ++ ret = sftl_read_page(sftl, members[member].lane & 1, ++ members[member].lane >> 1, members[member].block, ++ page, true, sftl->page, sftl->oob); ++ if (ret) ++ goto out; ++ if (sftl->oob[0] != 0x20 || sftl->oob[1] != lane || ++ sftl->oob[2] || sftl->oob[3]) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ if (member == group * 4) { ++ memcpy(table, sftl->page, APPLE_PPN_PAGE_SIZE); ++ memcpy(table + APPLE_PPN_PAGE_SIZE, sftl->oob, sizeof(sftl->oob)); ++ } else if (memcmp(table, sftl->page, APPLE_PPN_PAGE_SIZE) || ++ memcmp(table + APPLE_PPN_PAGE_SIZE, ++ sftl->oob, sizeof(sftl->oob))) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ } ++ for (block = 0; block < SFTL_BLOCKS; block++) ++ if (!(get_unaligned_le16(table + block * 2) & BIT(15))) ++ sftl->lanes[block] |= BIT(lane); ++ } ++ ret = 0; ++out: ++ kfree(table); ++ kfree(best); ++ return ret; ++} ++ ++static int sftl_context_names(struct apple_sftl *sftl, const u8 *payload, ++ u16 *pending, u32 *count) ++{ ++ u32 names = get_unaligned_le32(payload), i; ++ ++ if (!names || names > SFTL_MAX_CONTEXTS) ++ return -EBADMSG; ++ for (i = 0; i < names; i++) { ++ u32 name = get_unaligned_le32(payload + 4 + i * 4); ++ ++ if (!name || name >= SFTL_SUBBLOCKS) ++ return -EBADMSG; ++ if (test_bit(name, sftl->reserved)) ++ continue; ++ if (*count == SFTL_MAX_CONTEXTS) ++ return -E2BIG; ++ __set_bit(name, sftl->reserved); ++ pending[(*count)++] = name; ++ } ++ return 0; ++} ++ ++static int sftl_context(struct apple_sftl *sftl, const u8 *heads) ++{ ++ struct sftl_records records = {}; ++ u16 pending[SFTL_MAX_CONTEXTS]; ++ u32 count = 0, block, at, i, first = 0, cursor = 0, chunk; ++ u64 previous = 0, fragment_sequence = 0; ++ int ret = -EBADMSG; ++ ++ for (block = 1; block < SFTL_SUBBLOCKS; block++) { ++ if (heads[block * SFTL_META] != 0x1f) ++ continue; ++ if (count == SFTL_MAX_CONTEXTS) ++ goto out; ++ pending[count++] = block; ++ __set_bit(block, sftl->reserved); ++ } ++ for (at = 0; at < count; at++) { ++ u32 position, pages; ++ ++ block = pending[at]; ++ pages = hweight16(sftl->lanes[block]) * 128; ++ if (!pages) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ for (position = 0; position < pages; position++) { ++ ret = sftl_read_address(sftl, block * SFTL_STRIDE + position * 4, &chunk); ++ if (ret) ++ goto out; ++ if (sftl_erased(sftl->oob, sizeof(sftl->oob))) { ++ ret = sftl_erased(sftl->page, APPLE_PPN_PAGE_SIZE) ? 0 : -EBADMSG; ++ if (ret) ++ goto out; ++ break; ++ } ++ for (chunk = 0; chunk < 4; chunk++) { ++ const u8 *meta = sftl->oob + chunk * SFTL_META; ++ const u8 *payload = sftl->page + chunk * SFTL_CHUNK; ++ ++ if (sftl_erased(meta, SFTL_META)) ++ continue; ++ if (meta[0] != 0x1f || !meta[1] || meta[1] > 6) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ if (meta[1] == 1 && !chunk) { ++ ret = sftl_context_names(sftl, payload, pending, &count); ++ if (ret) ++ goto out; ++ } ++ ret = sftl_record_add(&records, sftl_stamp(meta), 0, ++ block * SFTL_STRIDE + position * 4 + chunk); ++ if (ret) ++ goto out; ++ } ++ } ++ } ++ ret = -EBADMSG; ++ if (!records.count) ++ goto out; ++ sort(records.items, records.count, sizeof(*records.items), sftl_record_compare, NULL); ++ if (sftl_flags(&records.items[0]) != 1) ++ goto out; ++ for (i = 1; i < records.count; i++) ++ if (sftl_flags(&records.items[i]) == 1 && sftl_flags(&records.items[i - 1]) != 1) ++ first = i; ++ if (sftl_flags(&records.items[records.count - 1]) != 4) ++ goto out; ++ for (i = first; i < records.count; i++) { ++ const struct sftl_record *record = &records.items[i]; ++ u64 sequence = record->stamp & SFTL_SEQUENCE; ++ const u8 *payload; ++ u32 offset, value, length, names; ++ ++ if (i != first && sequence != previous + 1) ++ goto out; ++ previous = sequence; ++ ret = sftl_read_address(sftl, record->address, &chunk); ++ if (ret) ++ goto out; ++ ret = -EBADMSG; ++ if (sftl_stamp(sftl->oob + chunk * SFTL_META) != record->stamp) ++ goto out; ++ payload = sftl->page + chunk * SFTL_CHUNK; ++ if (i == first) { ++ names = get_unaligned_le32(payload); ++ if (!names || names > SFTL_MAX_CONTEXTS) ++ goto out; ++ for (offset = 0; offset < names; offset++) { ++ value = get_unaligned_le32(payload + 4 + offset * 4); ++ if (value >= SFTL_BLOCKS || !test_bit(value, sftl->reserved)) ++ goto out; ++ } ++ } ++ if (sftl_flags(record) != 4) ++ continue; ++ if (fragment_sequence && sequence != fragment_sequence + 1) ++ goto out; ++ fragment_sequence = sequence; ++ value = get_unaligned_le32(payload + 4); ++ if (value == U32_MAX) ++ continue; ++ if (value != 0xfffffff0 || get_unaligned_le32(payload) != cursor) ++ goto out; ++ for (offset = 8; offset < SFTL_CHUNK; offset += 8) { ++ value = get_unaligned_le32(payload + offset); ++ length = get_unaligned_le32(payload + offset + 4); ++ if (value == U32_MAX) ++ break; ++ if (!length || length > SFTL_LPAGES - cursor) ++ goto out; ++ if (value == BIT(fls(SFTL_PROVIDER * 2 - 1) - 1) - 1) ++ value = SFTL_UNMAPPED; ++ ret = sftl_add_run(sftl, cursor, cursor + length, value); ++ if (ret) ++ goto out; ++ cursor += length; ++ ret = -EBADMSG; ++ } ++ } ++ if (cursor != SFTL_LPAGES || !fragment_sequence) ++ goto out; ++ sftl->checkpoint_sequence = fragment_sequence; ++ sftl->sequence = fragment_sequence; ++ ret = 0; ++out: ++ kvfree(records.items); ++ return ret; ++} ++ ++static int sftl_add_journal(struct sftl_records *journal, const u8 *meta, ++ u32 address, u64 checkpoint) ++{ ++ u64 sequence = sftl_sequence(meta); ++ ++ if (sequence <= checkpoint || sftl_erased(meta, SFTL_META)) ++ return 0; ++ if ((meta[0] != 1 && meta[0] != 2) || ++ get_unaligned_le32(meta + 12) != sequence >> 16 || ++ (meta[0] == 1 ? meta[1] > 1 : meta[1] != 0)) ++ return -EBADMSG; ++ return sftl_record_add(journal, sftl_stamp(meta), get_unaligned_le32(meta + 8), address); ++} ++ ++static int sftl_footer(struct apple_sftl *sftl, const u8 *head, u32 block, ++ u32 pages, struct sftl_records *journal, u64 *floor) ++{ ++ u8 footer_meta[16]; ++ u8 *footer; ++ u32 chunks, span, index, position = 0, relative_end = 0; ++ u64 base; ++ unsigned int chunk; ++ bool terminated = false; ++ int ret; ++ ++ memcpy(footer_meta, sftl->oob, sizeof(footer_meta)); ++ base = sftl_sequence(footer_meta); ++ chunks = get_unaligned_le16(footer_meta + 12); ++ span = get_unaligned_le32(footer_meta + 8); ++ if (chunks < 4 || chunks > 32 || chunks % 4 || footer_meta[1] >> 1 != chunks || ++ get_unaligned_le16(footer_meta + 14) != 0xffff || ++ base != sftl_sequence(head) || !span || base + span > SFTL_SEQUENCE) ++ return -EBADMSG; ++ *floor = max(*floor, base + span - 1); ++ if (base + span - 1 <= sftl->checkpoint_sequence) ++ return 0; ++ footer = kmalloc(chunks * SFTL_CHUNK, GFP_KERNEL); ++ if (!footer) ++ return -ENOMEM; ++ for (index = 0; index < chunks / 4; index++) { ++ ret = sftl_read_address(sftl, block * SFTL_STRIDE + ++ (pages - chunks / 4 + index) * 4, &chunk); ++ if (ret) ++ goto out; ++ for (chunk = 0; chunk < 4; chunk++) ++ if (memcmp(footer_meta, sftl->oob + chunk * SFTL_META, SFTL_META)) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ memcpy(footer + index * APPLE_PPN_PAGE_SIZE, sftl->page, APPLE_PPN_PAGE_SIZE); ++ } ++ for (index = 0; index < chunks * SFTL_CHUNK; index += 16) { ++ u32 relative = get_unaligned_le32(footer + index); ++ u32 key = get_unaligned_le32(footer + index + 8); ++ u32 length = get_unaligned_le32(footer + index + 12), offset; ++ ++ if (!length) { ++ terminated = true; ++ break; ++ } ++ if (relative < relative_end || length > U32_MAX - relative || ++ base + relative + length > SFTL_SEQUENCE + 1 || ++ length > pages * 4 - chunks - position) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ relative_end = relative + length; ++ for (offset = 0; offset < length; offset++, position++) { ++ u64 sequence = base + relative + offset; ++ u32 actual_key = key >= 0xffff0000 ? key : key + offset; ++ u32 address = block * SFTL_STRIDE + position; ++ u8 meta[16]; ++ ++ if (sequence <= sftl->checkpoint_sequence) ++ continue; ++ if (key < 0xffff0000 && actual_key < key) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ if (actual_key == SFTL_TRIM) { ++ ret = sftl_read_address(sftl, address, &chunk); ++ if (ret) ++ goto out; ++ memcpy(meta, sftl->oob + chunk * SFTL_META, sizeof(meta)); ++ if (meta[0] != 2 || meta[1] || sftl_sequence(meta) != sequence || ++ get_unaligned_le32(meta + 8) != SFTL_TRIM) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ } else { ++ meta[0] = 1; ++ meta[1] = head[1]; ++ put_unaligned_le64(sequence << 16 | get_unaligned_le16(meta), meta); ++ put_unaligned_le32(actual_key, meta + 8); ++ put_unaligned_le32(sequence >> 16, meta + 12); ++ } ++ ret = sftl_add_journal(journal, meta, address, sftl->checkpoint_sequence); ++ if (ret) ++ goto out; ++ } ++ } ++ ret = terminated && position == pages * 4 - chunks ? 0 : -EBADMSG; ++out: ++ kfree(footer); ++ return ret; ++} ++ ++static int sftl_journal(struct apple_sftl *sftl, const u8 *heads) ++{ ++ struct sftl_records journal = {}; ++ u64 floor = sftl->checkpoint_sequence, expected = floor + 1; ++ u32 block, position, i, pages; ++ unsigned int chunk; ++ int ret; ++ ++ for (block = 1; block < SFTL_SUBBLOCKS; block++) { ++ const u8 *head = heads + block * SFTL_META; ++ ++ if (head[0] != 1 && head[0] != 2) ++ continue; ++ pages = hweight16(sftl->lanes[block]) * 128; ++ ret = sftl_read_address(sftl, block * SFTL_STRIDE + (pages - 1) * 4, &chunk); ++ if (ret) ++ goto out; ++ if (sftl->oob[0] == 0x1c) { ++ ret = sftl_footer(sftl, head, block, pages, &journal, &floor); ++ if (ret) ++ goto out; ++ continue; ++ } ++ if (!sftl_erased(sftl->oob, sizeof(sftl->oob)) || ++ !sftl_erased(sftl->page, APPLE_PPN_PAGE_SIZE)) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ for (position = 0; position < pages; position++) { ++ ret = sftl_read_address(sftl, block * SFTL_STRIDE + position * 4, &chunk); ++ if (ret) ++ goto out; ++ if (sftl_erased(sftl->oob, sizeof(sftl->oob)) && ++ !sftl_erased(sftl->page, APPLE_PPN_PAGE_SIZE)) { ++ ret = -EBADMSG; ++ goto out; ++ } ++ for (chunk = 0; chunk < 4; chunk++) { ++ ret = sftl_add_journal(&journal, sftl->oob + chunk * SFTL_META, ++ block * SFTL_STRIDE + position * 4 + chunk, ++ sftl->checkpoint_sequence); ++ if (ret) ++ goto out; ++ } ++ } ++ } ++ sort(journal.items, journal.count, sizeof(*journal.items), sftl_record_compare, NULL); ++ for (i = 0; i < journal.count; i++) { ++ const struct sftl_record *record = &journal.items[i]; ++ u64 sequence = record->stamp & SFTL_SEQUENCE; ++ ++ ret = -EBADMSG; ++ if (sequence != expected++) ++ goto out; ++ if (sftl_kind(record) == 2 && record->key == SFTL_TRIM) { ++ const u8 *payload; ++ u32 count, offset; ++ ++ ret = sftl_read_address(sftl, record->address, &chunk); ++ if (ret) ++ goto out; ++ ret = -EBADMSG; ++ if (sftl_stamp(sftl->oob + chunk * SFTL_META) != record->stamp || ++ get_unaligned_le32(sftl->oob + chunk * SFTL_META + 8) != record->key) ++ goto out; ++ payload = sftl->page + chunk * SFTL_CHUNK; ++ count = get_unaligned_le32(payload); ++ if (count > (SFTL_CHUNK - 4) / 8) ++ goto out; ++ for (offset = 0; offset < count; offset++) { ++ u32 start = get_unaligned_le32(payload + 4 + offset * 8); ++ u32 length = get_unaligned_le32(payload + 8 + offset * 8); ++ ++ if (!length) ++ continue; ++ ret = sftl_replace(sftl, start, length, SFTL_UNMAPPED); ++ if (ret) ++ goto out; ++ } ++ } else { ++ ret = sftl_replace(sftl, record->key, 1, record->address); ++ if (ret) ++ goto out; ++ } ++ sftl->sequence = sequence; ++ } ++ sftl->sequence = max(sftl->sequence, floor); ++ ret = 0; ++out: ++ kvfree(journal.items); ++ return ret; ++} ++ ++int apple_sftl_load(struct apple_ppn *provider, struct apple_sftl **out) ++{ ++ struct apple_sftl *sftl; ++ u8 *heads; ++ u32 block; ++ unsigned int chunk; ++ int ret; ++ ++ if (!out) ++ return -EINVAL; ++ *out = NULL; ++ if (!provider || !provider->ops || !provider->ops->read_page || ++ provider->buses != 2 || provider->caus != 8 || ++ provider->blocks != SFTL_BLOCKS || provider->pages != 128) ++ return -EOPNOTSUPP; ++ sftl = kzalloc(sizeof(*sftl), GFP_KERNEL); ++ if (!sftl) ++ return -ENOMEM; ++ sftl->ppn = provider; ++ sftl->runs = RB_ROOT; ++ mutex_init(&sftl->lock); ++ sftl->page = kmalloc(APPLE_PPN_PAGE_SIZE, GFP_KERNEL); ++ heads = kcalloc(SFTL_BLOCKS, SFTL_META, GFP_KERNEL); ++ if (!sftl->page || !heads) { ++ ret = -ENOMEM; ++ goto failed; ++ } ++ memset(heads, 0xff, SFTL_BLOCKS * SFTL_META); ++ ret = sftl_vfl(sftl); ++ if (ret) ++ goto failed; ++ for (block = 1; block < SFTL_SUBBLOCKS; block++) { ++ if (!sftl->lanes[block]) ++ continue; ++ ret = sftl_read_address(sftl, block * SFTL_STRIDE, &chunk); ++ if (ret) ++ goto failed; ++ if (sftl_erased(sftl->oob, sizeof(sftl->oob))) { ++ if (!sftl_erased(sftl->page, APPLE_PPN_PAGE_SIZE)) { ++ ret = -EBADMSG; ++ goto failed; ++ } ++ } else if (sftl->oob[0] == 0x1f || sftl->oob[0] == 1 || sftl->oob[0] == 2) { ++ memcpy(heads + block * SFTL_META, sftl->oob, SFTL_META); ++ } else { ++ ret = -EOPNOTSUPP; ++ goto failed; ++ } ++ } ++ ret = sftl_context(sftl, heads); ++ if (ret) ++ goto failed; ++ ret = sftl_journal(sftl, heads); ++ if (ret) ++ goto failed; ++ kfree(heads); ++ *out = sftl; ++ return 0; ++failed: ++ kfree(heads); ++ apple_sftl_free(sftl); ++ return ret; ++} ++EXPORT_SYMBOL_GPL(apple_sftl_load); ++ ++void apple_sftl_free(struct apple_sftl *sftl) ++{ ++ struct rb_node *node; ++ ++ if (!sftl) ++ return; ++ while ((node = rb_first(&sftl->runs))) { ++ rb_erase(node, &sftl->runs); ++ kfree(rb_entry(node, struct sftl_run, node)); ++ } ++ kfree(sftl->page); ++ kfree(sftl); ++} ++EXPORT_SYMBOL_GPL(apple_sftl_free); ++ ++int apple_sftl_read(struct apple_sftl *sftl, u32 logical_page, u8 *data) ++{ ++ struct sftl_run *run; ++ unsigned int chunk; ++ u32 address; ++ const u8 *meta; ++ int ret = 0; ++ ++ if (!sftl || !data || logical_page >= SFTL_LPAGES) ++ return -EINVAL; ++ mutex_lock(&sftl->lock); ++ if (sftl->failed) { ++ ret = -EIO; ++ goto out; ++ } ++ run = sftl_find(sftl, logical_page); ++ if (!run) { ++ ret = -EBADMSG; ++ goto failed; ++ } ++ if (run->provider == SFTL_UNMAPPED) { ++ memset(data, 0, SFTL_CHUNK); ++ goto out; ++ } ++ address = run->provider + logical_page - run->start; ++ ret = sftl_read_address(sftl, address, &chunk); ++ if (ret) ++ goto failed; ++ meta = sftl->oob + chunk * SFTL_META; ++ if ((meta[0] != 1 && meta[0] != 2) || get_unaligned_le32(meta + 8) != logical_page) { ++ ret = -EBADMSG; ++ goto failed; ++ } ++ memcpy(data, sftl->page + chunk * SFTL_CHUNK, SFTL_CHUNK); ++ goto out; ++failed: ++ sftl->failed = true; ++out: ++ mutex_unlock(&sftl->lock); ++ return ret; ++} ++EXPORT_SYMBOL_GPL(apple_sftl_read); ++ ++void apple_sftl_get_info(struct apple_sftl *sftl, struct apple_sftl_info *info) ++{ ++ info->logical_pages = SFTL_LPAGES; ++ info->disk_pages = SFTL_DISK_PAGES; ++ info->nr_runs = sftl->nr_runs; ++ info->checkpoint_sequence = sftl->checkpoint_sequence; ++ info->sequence = sftl->sequence; ++} ++EXPORT_SYMBOL_GPL(apple_sftl_get_info); +diff --git a/drivers/mtd/apple-sftl-test.c b/drivers/mtd/apple-sftl-test.c +new file mode 100644 +index 000000000..e451704c9 +--- /dev/null ++++ b/drivers/mtd/apple-sftl-test.c +@@ -0,0 +1,436 @@ ++// SPDX-License-Identifier: GPL-2.0-only ++/* Synthetic VFL/SFTL medium. No firmware or captured device bytes. */ ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include "apple-sftl.h" ++ ++#define TEST_LOGICAL_PAGES 15625016U ++#define TEST_STRIDE 8192U ++#define TEST_CHECKPOINT 101ULL ++#define TEST_CLOSED_FIRST 102ULL ++#define TEST_CLOSED_CHUNKS 1020U ++#define TEST_OPEN_FIRST (TEST_CLOSED_FIRST + TEST_CLOSED_CHUNKS) ++ ++enum sftl_fault { ++ SFTL_NO_FAULT, ++ SFTL_CHECKPOINT_COPY, ++ SFTL_LANE_COPY, ++ SFTL_BOOTSTRAP_DUPLICATE, ++ SFTL_INCOMPLETE_CONTEXT, ++ SFTL_JOURNAL_GAP, ++ SFTL_JOURNAL_DUPLICATE, ++ SFTL_BAD_FOOTER, ++ SFTL_READ_FAILURE, ++ SFTL_REFRESH, ++ SFTL_TRIM, ++ SFTL_ALLOCATION_FLOOR, ++ SFTL_INACTIVE_ADDRESS, ++ SFTL_OPEN_HOLE, ++ SFTL_UNKNOWN_STATUS, ++ SFTL_TORN_FOOTER, ++ SFTL_RUNTIME_FAILURE, ++}; ++ ++struct sftl_fixture { ++ struct apple_ppn provider; ++ enum sftl_fault fault; ++ unsigned int reads; ++}; ++ ++static void sftl_test_meta(u8 *meta, u8 kind, u8 flags, u64 seq, u32 key) ++{ ++ unsigned int i; ++ ++ meta[0] = kind; ++ meta[1] = flags; ++ for (i = 0; i < 6; i++) ++ meta[2 + i] = seq >> (8 * i); ++ put_unaligned_le32(key, meta + 8); ++ put_unaligned_le32(seq >> 16, meta + 12); ++} ++ ++static void sftl_test_members(u8 *data, enum sftl_fault fault) ++{ ++ unsigned int i; ++ ++ for (i = 0; i < 8; i++) { ++ put_unaligned_le16(2087, data + 0x100 + i * 4); ++ data[0x102 + i * 4] = i; ++ data[0x103 + i * 4] = 0; ++ } ++ if (fault == SFTL_BOOTSTRAP_DUPLICATE) ++ data[0x106] = 0; ++} ++ ++static int sftl_test_context(struct sftl_fixture *f, unsigned int lane, ++ unsigned int page, u8 *data, u8 *oob, u8 *status) ++{ ++ unsigned int i; ++ ++ if (lane >= 8) ++ return 0; ++ if (!page) { ++ memset(data, 0, 16384); ++ put_unaligned_le16(16, data + 10); ++ put_unaligned_le16(2086, data + 12); ++ put_unaligned_le16(2086, data + 14); ++ sftl_test_members(data, f->fault); ++ for (i = 0; i < 16; i++) ++ put_unaligned_le16(i + 1, data + 0x200 + i * 32); ++ oob[0] = 0x20; ++ oob[1] = 0xff; ++ oob[2] = 0xff; ++ oob[3] = 0; ++ put_unaligned_le32(lane < 4 ? 0xfffffff0 : 0xfffffff1, oob + 4); ++ if (f->fault == SFTL_CHECKPOINT_COPY && lane == 1) ++ data[0] ^= 1; ++ *status = 0x40; ++ } else if (page <= 16) { ++ /* Lane tables occupy pages 1..16 in each of four replicas. */ ++ if (page <= 2) ++ for (i = 1; i <= 4; i++) ++ put_unaligned_le16(0, data + i * 2); ++ oob[0] = 0x20; ++ oob[1] = page - 1; ++ oob[2] = 0; ++ oob[3] = 0; ++ if (f->fault == SFTL_LANE_COPY && lane == 1 && page == 1) ++ put_unaligned_le16(0x8000, data + 2); ++ *status = 0x40; ++ } ++ return 0; ++} ++ ++static void sftl_test_checkpoint(struct sftl_fixture *f, u8 *data, u8 *oob) ++{ ++ u8 *ranges = data + 4096; ++ u32 address = 4 * TEST_STRIDE; ++ ++ memset(data, 0xff, 16384); ++ memset(data, 0, 4096); ++ put_unaligned_le32(1, data); ++ put_unaligned_le32(1, data + 4); ++ sftl_test_meta(oob, 0x1f, 1, 100, 0); ++ if (f->fault == SFTL_INCOMPLETE_CONTEXT) ++ return; ++ if (f->fault == SFTL_INACTIVE_ADDRESS) ++ address += 1024; ++ put_unaligned_le32(0, ranges); ++ put_unaligned_le32(0xfffffff0, ranges + 4); ++ put_unaligned_le32(address, ranges + 8); ++ put_unaligned_le32(1, ranges + 12); ++ put_unaligned_le32(0x1ffffff, ranges + 16); ++ put_unaligned_le32(TEST_LOGICAL_PAGES - 1, ranges + 20); ++ sftl_test_meta(oob + 16, 0x1f, 4, TEST_CHECKPOINT, 0); ++} ++ ++static void sftl_test_open(struct sftl_fixture *f, u8 *data, u8 *oob) ++{ ++ u64 first = TEST_OPEN_FIRST; ++ unsigned int i; ++ ++ if (f->fault == SFTL_JOURNAL_GAP) ++ first++; ++ if (f->fault == SFTL_JOURNAL_DUPLICATE) ++ first--; ++ for (i = 0; i < 4; i++) { ++ memset(data + i * 4096, 'D' + i, 4096); ++ sftl_test_meta(oob + i * 16, 1, 0, first + i, 2); ++ } ++ if (f->fault == SFTL_TRIM) { ++ memset(data, 0, 4096); ++ put_unaligned_le32(1, data); ++ put_unaligned_le32(0, data + 4); ++ put_unaligned_le32(1, data + 8); ++ sftl_test_meta(oob, 2, 0, first, 0xffff0001); ++ } ++} ++ ++static void sftl_test_closed(struct sftl_fixture *f, unsigned int position, ++ u8 *data, u8 *oob) ++{ ++ unsigned int i; ++ ++ if (position < 255) { ++ for (i = 0; i < 4; i++) { ++ u32 slot = position * 4 + i; ++ ++ memset(data + i * 4096, (slot + 1) & 0xff, 4096); ++ sftl_test_meta(oob + i * 16, 1, 0, ++ TEST_CLOSED_FIRST + slot, slot + 1); ++ } ++ } else { ++ u32 span = TEST_CLOSED_CHUNKS; ++ ++ memset(data, 0, 16384); ++ put_unaligned_le32(0, data); ++ put_unaligned_le32(0, data + 4); ++ put_unaligned_le32(1, data + 8); ++ put_unaligned_le32(TEST_CLOSED_CHUNKS, data + 12); ++ if (f->fault == SFTL_ALLOCATION_FLOOR) ++ span += 8; ++ sftl_test_meta(oob, 0x1c, 8, TEST_CLOSED_FIRST, span); ++ put_unaligned_le16(f->fault == SFTL_BAD_FOOTER ? 2 : 4, oob + 12); ++ put_unaligned_le16(0xffff, oob + 14); ++ for (i = 1; i < 4; i++) ++ memcpy(oob + i * 16, oob, 16); ++ if (f->fault == SFTL_TORN_FOOTER) ++ oob[63] ^= 1; ++ } ++} ++ ++static int sftl_test_read_page(void *priv, unsigned int bus, unsigned int cau, ++ unsigned int block, unsigned int page, ++ bool context, u8 *data, u8 *oob, u8 *status) ++{ ++ struct sftl_fixture *f = priv; ++ unsigned int lane = cau * 2 + bus; ++ unsigned int position = page * 2 + lane; ++ ++ f->reads++; ++ if (bus >= 2 || cau >= 8 || block >= 2088 || page >= (context ? 64 : 128)) ++ return -EINVAL; ++ memset(data, 0xff, 16384); ++ memset(oob, 0xff, 64); ++ *status = 0x49; ++ if (context) { ++ if (block == 2087) ++ return sftl_test_context(f, lane, page, data, oob, status); ++ return 0; ++ } ++ if (block == 3 && (f->fault == SFTL_RUNTIME_FAILURE || ++ (!position && f->fault == SFTL_READ_FAILURE))) ++ return -EIO; ++ if (lane > 1) ++ return 0; ++ if (block == 1 && !position) { ++ sftl_test_checkpoint(f, data, oob); ++ } else if (block == 2 && !position) { ++ sftl_test_open(f, data, oob); ++ } else if (block == 2 && position == 2 && f->fault == SFTL_OPEN_HOLE) { ++ unsigned int i; ++ ++ for (i = 0; i < 4; i++) { ++ memset(data + i * 4096, 'H' + i, 4096); ++ sftl_test_meta(oob + i * 16, 1, 0, TEST_OPEN_FIRST + 4 + i, 3); ++ } ++ } else if (block == 3) { ++ sftl_test_closed(f, position, data, oob); ++ } else if (block == 4 && !position) { ++ memset(data, 'C', 4096); ++ sftl_test_meta(oob, 1, 0, 10, 0); ++ } else { ++ return 0; ++ } ++ *status = block == 2 && f->fault == SFTL_REFRESH ? 0x42 : 0x40; ++ if (block == 2 && f->fault == SFTL_UNKNOWN_STATUS) ++ *status = 0x61; ++ return 0; ++} ++ ++static const struct apple_ppn_ops sftl_test_ops = { ++ .read_page = sftl_test_read_page, ++}; ++ ++static struct sftl_fixture *sftl_test_fixture(struct kunit *test, ++ enum sftl_fault fault) ++{ ++ struct sftl_fixture *f = kunit_kzalloc(test, sizeof(*f), GFP_KERNEL); ++ ++ if (!f) ++ return NULL; ++ f->fault = fault; ++ f->provider.ops = &sftl_test_ops; ++ f->provider.priv = f; ++ f->provider.buses = 2; ++ f->provider.caus = 8; ++ f->provider.blocks = 2088; ++ f->provider.pages = 128; ++ return f; ++} ++ ++static void sftl_test_expect_fill(struct kunit *test, const u8 *data, u8 fill) ++{ ++ unsigned int i; ++ ++ for (i = 0; i < 4096; i++) ++ if (data[i] != fill) { ++ KUNIT_FAIL(test, "logical byte %u: expected %02x, got %02x", ++ i, fill, data[i]); ++ return; ++ } ++} ++ ++static void sftl_test_complete_medium(struct kunit *test) ++{ ++ struct sftl_fixture *f = sftl_test_fixture(test, SFTL_NO_FAULT); ++ struct apple_sftl_info info; ++ struct apple_sftl *sftl = NULL; ++ u8 *data = kunit_kmalloc(test, 4096, GFP_KERNEL); ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ KUNIT_ASSERT_NOT_NULL(test, data); ++ KUNIT_ASSERT_EQ(test, apple_sftl_load(&f->provider, &sftl), 0); ++ KUNIT_ASSERT_NOT_NULL(test, sftl); ++ apple_sftl_get_info(sftl, &info); ++ KUNIT_EXPECT_EQ(test, info.logical_pages, TEST_LOGICAL_PAGES); ++ KUNIT_EXPECT_EQ(test, info.checkpoint_sequence, TEST_CHECKPOINT); ++ KUNIT_EXPECT_EQ(test, info.sequence, TEST_OPEN_FIRST + 3); ++ KUNIT_EXPECT_EQ(test, info.nr_runs, 5U); ++ KUNIT_EXPECT_EQ(test, info.disk_pages, 15625000U); ++ KUNIT_EXPECT_LT(test, f->reads, 12000U); ++ KUNIT_EXPECT_EQ(test, apple_sftl_read(sftl, 0, data), 0); ++ sftl_test_expect_fill(test, data, 'C'); ++ KUNIT_EXPECT_EQ(test, apple_sftl_read(sftl, 2, data), 0); ++ sftl_test_expect_fill(test, data, 'G'); ++ KUNIT_EXPECT_EQ(test, apple_sftl_read(sftl, 6, data), 0); ++ sftl_test_expect_fill(test, data, 6); ++ KUNIT_EXPECT_EQ(test, apple_sftl_read(sftl, 2000, data), 0); ++ sftl_test_expect_fill(test, data, 0); ++ KUNIT_EXPECT_LT(test, apple_sftl_read(sftl, TEST_LOGICAL_PAGES, data), 0); ++ apple_sftl_free(sftl); ++} ++ ++static void sftl_test_reject_incomplete_or_ambiguous(struct kunit *test) ++{ ++ static const enum sftl_fault faults[] = { ++ SFTL_CHECKPOINT_COPY, SFTL_LANE_COPY, SFTL_BOOTSTRAP_DUPLICATE, ++ SFTL_INCOMPLETE_CONTEXT, SFTL_JOURNAL_GAP, ++ SFTL_JOURNAL_DUPLICATE, SFTL_BAD_FOOTER, ++ SFTL_READ_FAILURE, SFTL_REFRESH, SFTL_UNKNOWN_STATUS, ++ SFTL_TORN_FOOTER, ++ }; ++ unsigned int i; ++ ++ for (i = 0; i < ARRAY_SIZE(faults); i++) { ++ struct sftl_fixture *f = sftl_test_fixture(test, faults[i]); ++ struct apple_sftl *sftl = NULL; ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ KUNIT_EXPECT_LT_MSG(test, apple_sftl_load(&f->provider, &sftl), 0, ++ "fault %u", faults[i]); ++ KUNIT_EXPECT_PTR_EQ(test, sftl, NULL); ++ apple_sftl_free(sftl); ++ } ++} ++ ++static void sftl_test_trim_then_overwrite(struct kunit *test) ++{ ++ struct sftl_fixture *f = sftl_test_fixture(test, SFTL_TRIM); ++ struct apple_sftl *sftl = NULL; ++ u8 *data = kunit_kmalloc(test, 4096, GFP_KERNEL); ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ KUNIT_ASSERT_NOT_NULL(test, data); ++ KUNIT_ASSERT_EQ(test, apple_sftl_load(&f->provider, &sftl), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_read(sftl, 0, data), 0); ++ sftl_test_expect_fill(test, data, 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_read(sftl, 2, data), 0); ++ sftl_test_expect_fill(test, data, 'G'); ++ apple_sftl_free(sftl); ++} ++ ++static void sftl_test_preserve_footer_allocation_floor(struct kunit *test) ++{ ++ struct sftl_fixture *f = sftl_test_fixture(test, SFTL_ALLOCATION_FLOOR); ++ struct apple_sftl_info info; ++ struct apple_sftl *sftl = NULL; ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ KUNIT_ASSERT_EQ(test, apple_sftl_load(&f->provider, &sftl), 0); ++ apple_sftl_get_info(sftl, &info); ++ KUNIT_EXPECT_EQ(test, info.sequence, ++ TEST_CLOSED_FIRST + TEST_CLOSED_CHUNKS + 8 - 1); ++ apple_sftl_free(sftl); ++} ++ ++static void sftl_test_reject_other_geometry(struct kunit *test) ++{ ++ struct sftl_fixture *f = sftl_test_fixture(test, SFTL_NO_FAULT); ++ struct apple_sftl *sftl = NULL; ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ f->provider.caus = 2; ++ KUNIT_EXPECT_LT(test, apple_sftl_load(&f->provider, &sftl), 0); ++ KUNIT_EXPECT_PTR_EQ(test, sftl, NULL); ++ KUNIT_EXPECT_EQ(test, f->reads, 0U); ++} ++ ++static void sftl_test_reject_inactive_lane_gap(struct kunit *test) ++{ ++ struct sftl_fixture *f = sftl_test_fixture(test, SFTL_INACTIVE_ADDRESS); ++ struct apple_sftl *sftl = NULL; ++ u8 *data = kunit_kmalloc(test, 4096, GFP_KERNEL); ++ int ret; ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ KUNIT_ASSERT_NOT_NULL(test, data); ++ ret = apple_sftl_load(&f->provider, &sftl); ++ if (!ret) { ++ KUNIT_EXPECT_LT(test, apple_sftl_read(sftl, 0, data), 0); ++ apple_sftl_free(sftl); ++ } else { ++ KUNIT_EXPECT_PTR_EQ(test, sftl, NULL); ++ } ++} ++ ++static void sftl_test_scan_after_open_lane_hole(struct kunit *test) ++{ ++ struct sftl_fixture *f = sftl_test_fixture(test, SFTL_OPEN_HOLE); ++ struct apple_sftl *sftl = NULL; ++ struct apple_sftl_info info; ++ u8 *data = kunit_kmalloc(test, 4096, GFP_KERNEL); ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ KUNIT_ASSERT_NOT_NULL(test, data); ++ KUNIT_ASSERT_EQ(test, apple_sftl_load(&f->provider, &sftl), 0); ++ apple_sftl_get_info(sftl, &info); ++ KUNIT_EXPECT_EQ(test, info.sequence, TEST_OPEN_FIRST + 7); ++ KUNIT_EXPECT_EQ(test, apple_sftl_read(sftl, 3, data), 0); ++ sftl_test_expect_fill(test, data, 'K'); ++ apple_sftl_free(sftl); ++} ++ ++static void sftl_test_runtime_failure_stops_reads(struct kunit *test) ++{ ++ struct sftl_fixture *f = sftl_test_fixture(test, SFTL_NO_FAULT); ++ struct apple_sftl *sftl = NULL; ++ u8 *data = kunit_kmalloc(test, 4096, GFP_KERNEL); ++ unsigned int reads; ++ ++ KUNIT_ASSERT_NOT_NULL(test, f); ++ KUNIT_ASSERT_NOT_NULL(test, data); ++ KUNIT_ASSERT_EQ(test, apple_sftl_load(&f->provider, &sftl), 0); ++ f->fault = SFTL_RUNTIME_FAILURE; ++ KUNIT_EXPECT_LT(test, apple_sftl_read(sftl, 6, data), 0); ++ reads = f->reads; ++ KUNIT_EXPECT_LT(test, apple_sftl_read(sftl, 0, data), 0); ++ KUNIT_EXPECT_EQ(test, f->reads, reads); ++ apple_sftl_free(sftl); ++} ++ ++static struct kunit_case apple_sftl_cases[] = { ++ KUNIT_CASE(sftl_test_complete_medium), ++ KUNIT_CASE(sftl_test_scan_after_open_lane_hole), ++ KUNIT_CASE(sftl_test_runtime_failure_stops_reads), ++ KUNIT_CASE(sftl_test_reject_incomplete_or_ambiguous), ++ KUNIT_CASE(sftl_test_trim_then_overwrite), ++ KUNIT_CASE(sftl_test_preserve_footer_allocation_floor), ++ KUNIT_CASE(sftl_test_reject_other_geometry), ++ KUNIT_CASE(sftl_test_reject_inactive_lane_gap), ++ {} ++}; ++ ++static struct kunit_suite apple_sftl_suite = { ++ .name = "apple-sftl", ++ .test_cases = apple_sftl_cases, ++}; ++ ++kunit_test_suite(apple_sftl_suite); ++ ++MODULE_LICENSE("GPL"); +diff --git a/drivers/mtd/apple-sftl.h b/drivers/mtd/apple-sftl.h +new file mode 100644 +index 000000000..7a05b95d1 +--- /dev/null ++++ b/drivers/mtd/apple-sftl.h +@@ -0,0 +1,23 @@ ++/* SPDX-License-Identifier: GPL-2.0-only */ ++#ifndef _APPLE_SFTL_H ++#define _APPLE_SFTL_H ++ ++#include ++#include ++ ++struct apple_sftl; ++ ++struct apple_sftl_info { ++ u32 logical_pages; ++ u32 disk_pages; ++ u32 nr_runs; ++ u64 checkpoint_sequence; ++ u64 sequence; ++}; ++ ++int apple_sftl_load(struct apple_ppn *provider, struct apple_sftl **out); ++void apple_sftl_free(struct apple_sftl *sftl); ++int apple_sftl_read(struct apple_sftl *sftl, u32 logical_page, u8 *data); ++void apple_sftl_get_info(struct apple_sftl *sftl, struct apple_sftl_info *info); ++ ++#endif +diff --git a/tools/testing/kunit/configs/apple_sftl.config b/tools/testing/kunit/configs/apple_sftl.config +new file mode 100644 +index 000000000..5ee7e49bb +--- /dev/null ++++ b/tools/testing/kunit/configs/apple_sftl.config +@@ -0,0 +1,4 @@ ++CONFIG_KUNIT=y ++CONFIG_MTD=y ++CONFIG_MTD_APPLE_SFTL=y ++CONFIG_MTD_APPLE_SFTL_KUNIT_TEST=y diff --git a/kernel/patches/drivers/mtd/0004-mtd-add-read-only-Apple-VFL-and-SwissPPNFTL-recovery.patch.toml b/kernel/patches/drivers/mtd/0004-mtd-add-read-only-Apple-VFL-and-SwissPPNFTL-recovery.patch.toml new file mode 100644 index 00000000..1248e2bf --- /dev/null +++ b/kernel/patches/drivers/mtd/0004-mtd-add-read-only-Apple-VFL-and-SwissPPNFTL-recovery.patch.toml @@ -0,0 +1,2 @@ +schema = 1 +platforms = ["apple-armv7"] diff --git a/kernel/patches/drivers/mtd/0005-mtd-apple-sftl-bound-read-only-provider-lifetime.patch b/kernel/patches/drivers/mtd/0005-mtd-apple-sftl-bound-read-only-provider-lifetime.patch new file mode 100644 index 00000000..c5153b62 --- /dev/null +++ b/kernel/patches/drivers/mtd/0005-mtd-apple-sftl-bound-read-only-provider-lifetime.patch @@ -0,0 +1,315 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> +Date: Fri, 6 Nov 2026 12:26:04 -0600 +Subject: [PATCH] mtd: apple-sftl: bound read-only provider lifetime + +Count physical page requests across map recovery and block reads. Apply a finite deadline before each provider call and check it again when the transaction completes, so one controller transaction bounds any overrun. Keep the reader failed after a provider error or exhausted policy, including logical reads that do not need flash I/O. + +Expose positive read-only policy parameters with defaults of 32768 requests and 600000 milliseconds for manual qualification sessions. These limits describe software lifetime rather than hardware behaviour. Add KUnit boundary cases for both limits and provider failures. + +Assisted-by: LLM +--- + Documentation/driver-api/mtd/apple-sftl.rst | 10 +++ + drivers/mtd/apple-sftl-block.c | 55 ++++++++++++++++- + drivers/mtd/apple-sftl-limits.h | 67 +++++++++++++++++++++ + drivers/mtd/apple-sftl-test.c | 62 +++++++++++++++++++ + 4 files changed, 193 insertions(+), 1 deletion(-) + create mode 100644 drivers/mtd/apple-sftl-limits.h + +diff --git a/Documentation/driver-api/mtd/apple-sftl.rst b/Documentation/driver-api/mtd/apple-sftl.rst +index 8295a5a2a..ade96fde7 100644 +--- a/Documentation/driver-api/mtd/apple-sftl.rst ++++ b/Documentation/driver-api/mtd/apple-sftl.rst +@@ -46,6 +46,16 @@ frontend freezes and drains requests during removal. Its request buffer is + allocated before the disk becomes visible, while blocking requests serialize + access to that buffer and the provider. + ++The reader has finite operating limits. ``max_page_reads`` defaults to ++32,768 physical page requests and ``max_io_time_ms`` defaults to ten minutes ++from probe. Both are read-only module parameters and must be positive. ++Recovery and subsequent block reads consume the same budget. A limit check ++runs before each provider call; completion also checks the deadline, so an ++existing bounded provider transaction is the maximum overrun. Any provider ++failure or exhausted limit permanently stops that reader instance. These ++limits support bounded read-only qualification; a mounted root must not rely ++on an instance after its operating budget expires. ++ + Tests + ----- + +diff --git a/drivers/mtd/apple-sftl-block.c b/drivers/mtd/apple-sftl-block.c +index 86d02921b..bf7a0a108 100644 +--- a/drivers/mtd/apple-sftl-block.c ++++ b/drivers/mtd/apple-sftl-block.c +@@ -4,18 +4,31 @@ + #include + #include + #include ++#include + #include + #include + #include + #include + + #include "apple-sftl.h" ++#include "apple-sftl-limits.h" + + #define SFTL_BLOCK_NAME "apple-sftl" + #define SFTL_DISK_MINORS 16 + #define SFTL_LOGICAL_BYTES 4096U + ++/* Limits bound one manually loaded reader instance, including recovery. */ ++static unsigned int max_page_reads = 32768; ++module_param(max_page_reads, uint, 0400); ++MODULE_PARM_DESC(max_page_reads, "Maximum physical page reads per reader instance"); ++static unsigned int max_io_time_ms = 600000; ++module_param(max_io_time_ms, uint, 0400); ++MODULE_PARM_DESC(max_io_time_ms, "Maximum reader lifetime in milliseconds"); ++ + struct sftl_disk { ++ struct apple_ppn *upstream; ++ struct apple_ppn provider; ++ struct apple_sftl_io_limit limit; + struct apple_sftl *ftl; + struct blk_mq_tag_set tags; + struct gendisk *disk; +@@ -25,6 +38,25 @@ struct sftl_disk { + bool dead; + }; + ++static int sftl_limited_read(void *priv, unsigned int bus, unsigned int cau, ++ unsigned int block, unsigned int page, bool context, ++ u8 *data, u8 *oob, u8 *status) ++{ ++ struct sftl_disk *sftl = priv; ++ int ret; ++ ++ ret = apple_sftl_limit_begin(&sftl->limit, ktime_get_ns()); ++ if (ret) ++ return ret; ++ ret = sftl->upstream->ops->read_page(sftl->upstream->priv, bus, cau, ++ block, page, context, data, oob, status); ++ return apple_sftl_limit_end(&sftl->limit, ret, ktime_get_ns()); ++} ++ ++static const struct apple_ppn_ops sftl_limited_ops = { ++ .read_page = sftl_limited_read, ++}; ++ + static DEFINE_IDA(sftl_disk_ids); + static int sftl_major; + +@@ -77,6 +109,8 @@ static blk_status_t sftl_queue_rq(struct blk_mq_hw_ctx *hctx, + mutex_lock(&sftl->io_lock); + if (sftl->dead) { + ret = -ENODEV; ++ } else if ((ret = apple_sftl_limit_check(&sftl->limit, ktime_get_ns()))) { ++ /* Includes logical pages which need no provider transaction. */ + } else if (req_op(request) == REQ_OP_FLUSH) { + /* A read-only device has no buffered writes to flush. */ + } else if (req_op(request) != REQ_OP_READ) { +@@ -125,9 +159,21 @@ static int sftl_disk_probe(struct platform_device *pdev) + ret = -ENOMEM; + goto free_state; + } +- ret = apple_sftl_load(provider, &sftl->ftl); ++ sftl->upstream = provider; ++ sftl->provider = *provider; ++ sftl->provider.ops = &sftl_limited_ops; ++ sftl->provider.priv = sftl; ++ ret = apple_sftl_limit_start(&sftl->limit, max_page_reads, ++ max_io_time_ms, ktime_get_ns()); + if (ret) + goto free_buffer; ++ dev_info(&pdev->dev, "reader policy: %u page reads, %u ms\n", ++ max_page_reads, max_io_time_ms); ++ ret = apple_sftl_load(&sftl->provider, &sftl->ftl); ++ if (ret) ++ goto free_buffer; ++ dev_info(&pdev->dev, "recovery completed after %u physical page reads\n", ++ sftl->limit.reads); + apple_sftl_get_info(sftl->ftl, &info); + if (!info.disk_pages || info.disk_pages > info.logical_pages) { + ret = -EINVAL; +@@ -176,6 +222,9 @@ static int sftl_disk_probe(struct platform_device *pdev) + free_ftl: + apple_sftl_free(sftl->ftl); + free_buffer: ++ if (sftl->limit.failed) ++ dev_err(&pdev->dev, "reader stopped after %u page requests: %d\n", ++ sftl->limit.reads, ret); + kfree(sftl->buffer); + free_state: + kfree(sftl); +@@ -187,6 +236,8 @@ static void sftl_disk_remove(struct platform_device *pdev) + struct sftl_disk *sftl = platform_get_drvdata(pdev); + + mutex_lock(&sftl->io_lock); ++ dev_info(&pdev->dev, "reader retired after %u physical page reads; failed=%u\n", ++ sftl->limit.reads, sftl->limit.failed); + sftl->dead = true; + mutex_unlock(&sftl->io_lock); + /* del_gendisk freezes and drains the queue before the provider is freed. */ +@@ -211,6 +262,8 @@ static int __init sftl_init(void) + { + int ret; + ++ if (!max_page_reads || !max_io_time_ms) ++ return -EINVAL; + sftl_major = register_blkdev(0, SFTL_BLOCK_NAME); + if (sftl_major < 0) + return sftl_major; +diff --git a/drivers/mtd/apple-sftl-limits.h b/drivers/mtd/apple-sftl-limits.h +new file mode 100644 +index 000000000..315bb8499 +--- /dev/null ++++ b/drivers/mtd/apple-sftl-limits.h +@@ -0,0 +1,67 @@ ++/* SPDX-License-Identifier: GPL-2.0-only */ ++#ifndef _APPLE_SFTL_LIMITS_H ++#define _APPLE_SFTL_LIMITS_H ++ ++#include ++#include ++#include ++ ++/* The caller serializes begin/end with the provider transaction. */ ++struct apple_sftl_io_limit { ++ u64 deadline_ns; ++ u32 maximum_reads; ++ u32 reads; ++ bool failed; ++}; ++ ++static inline int apple_sftl_limit_start(struct apple_sftl_io_limit *limit, ++ u32 reads, u32 milliseconds, u64 now) ++{ ++ u64 duration = (u64)milliseconds * 1000000ULL; ++ ++ if (!reads || !milliseconds || ++ check_add_overflow(now, duration, &limit->deadline_ns)) ++ return -EINVAL; ++ limit->maximum_reads = reads; ++ limit->reads = 0; ++ limit->failed = false; ++ return 0; ++} ++ ++static inline int apple_sftl_limit_check(struct apple_sftl_io_limit *limit, u64 now) ++{ ++ if (limit->failed) ++ return -EIO; ++ if (now < limit->deadline_ns) ++ return 0; ++ limit->failed = true; ++ return -ETIMEDOUT; ++} ++ ++static inline int apple_sftl_limit_begin(struct apple_sftl_io_limit *limit, u64 now) ++{ ++ int ret = apple_sftl_limit_check(limit, now); ++ ++ if (ret) ++ return ret; ++ if (limit->reads >= limit->maximum_reads) ++ ret = -E2BIG; ++ if (ret) { ++ limit->failed = true; ++ return ret; ++ } ++ limit->reads++; ++ return 0; ++} ++ ++static inline int apple_sftl_limit_end(struct apple_sftl_io_limit *limit, ++ int result, u64 now) ++{ ++ if (!result && now >= limit->deadline_ns) ++ result = -ETIMEDOUT; ++ if (result) ++ limit->failed = true; ++ return result; ++} ++ ++#endif +diff --git a/drivers/mtd/apple-sftl-test.c b/drivers/mtd/apple-sftl-test.c +index e451704c9..88308aadb 100644 +--- a/drivers/mtd/apple-sftl-test.c ++++ b/drivers/mtd/apple-sftl-test.c +@@ -8,6 +8,7 @@ + #include + + #include "apple-sftl.h" ++#include "apple-sftl-limits.h" + + #define TEST_LOGICAL_PAGES 15625016U + #define TEST_STRIDE 8192U +@@ -414,7 +415,68 @@ static void sftl_test_runtime_failure_stops_reads(struct kunit *test) + apple_sftl_free(sftl); + } + ++static void sftl_test_page_budget_stops_before_next_read(struct kunit *test) ++{ ++ struct apple_sftl_io_limit limit; ++ ++ KUNIT_ASSERT_EQ(test, apple_sftl_limit_start(&limit, 2, 1, 0), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 0), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_end(&limit, 0, 1), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 2), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_end(&limit, 0, 3), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 4), -E2BIG); ++ KUNIT_EXPECT_EQ(test, limit.reads, 2U); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 5), -EIO); ++ KUNIT_EXPECT_EQ(test, limit.reads, 2U); ++} ++ ++static void sftl_test_deadline_stops_before_next_read(struct kunit *test) ++{ ++ struct apple_sftl_io_limit limit; ++ ++ KUNIT_ASSERT_EQ(test, apple_sftl_limit_start(&limit, 4, 1, 10), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 1000010), -ETIMEDOUT); ++ KUNIT_EXPECT_EQ(test, limit.reads, 0U); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 10), -EIO); ++} ++ ++static void sftl_test_one_transaction_deadline_overrun(struct kunit *test) ++{ ++ struct apple_sftl_io_limit limit; ++ ++ KUNIT_ASSERT_EQ(test, apple_sftl_limit_start(&limit, 4, 1, 0), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 999999), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_end(&limit, 0, 1000000), -ETIMEDOUT); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 1000001), -EIO); ++ KUNIT_EXPECT_EQ(test, limit.reads, 1U); ++} ++ ++static void sftl_test_provider_failure_is_sticky(struct kunit *test) ++{ ++ struct apple_sftl_io_limit limit; ++ ++ KUNIT_ASSERT_EQ(test, apple_sftl_limit_start(&limit, 4, 1, 0), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 0), 0); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_end(&limit, -EBADMSG, 1), -EBADMSG); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_begin(&limit, 2), -EIO); ++ KUNIT_EXPECT_EQ(test, limit.reads, 1U); ++} ++ ++static void sftl_test_invalid_reader_policy(struct kunit *test) ++{ ++ struct apple_sftl_io_limit limit; ++ ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_start(&limit, 0, 1, 0), -EINVAL); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_start(&limit, 1, 0, 0), -EINVAL); ++ KUNIT_EXPECT_EQ(test, apple_sftl_limit_start(&limit, 1, 1, ~0ULL), -EINVAL); ++} ++ + static struct kunit_case apple_sftl_cases[] = { ++ KUNIT_CASE(sftl_test_page_budget_stops_before_next_read), ++ KUNIT_CASE(sftl_test_deadline_stops_before_next_read), ++ KUNIT_CASE(sftl_test_one_transaction_deadline_overrun), ++ KUNIT_CASE(sftl_test_provider_failure_is_sticky), ++ KUNIT_CASE(sftl_test_invalid_reader_policy), + KUNIT_CASE(sftl_test_complete_medium), + KUNIT_CASE(sftl_test_scan_after_open_lane_hole), + KUNIT_CASE(sftl_test_runtime_failure_stops_reads), diff --git a/kernel/patches/drivers/mtd/0005-mtd-apple-sftl-bound-read-only-provider-lifetime.patch.toml b/kernel/patches/drivers/mtd/0005-mtd-apple-sftl-bound-read-only-provider-lifetime.patch.toml new file mode 100644 index 00000000..1248e2bf --- /dev/null +++ b/kernel/patches/drivers/mtd/0005-mtd-apple-sftl-bound-read-only-provider-lifetime.patch.toml @@ -0,0 +1,2 @@ +schema = 1 +platforms = ["apple-armv7"] diff --git a/kernel/series.py b/kernel/series.py index 037dfbb0..54a89ada 100755 --- a/kernel/series.py +++ b/kernel/series.py @@ -80,17 +80,91 @@ def platform(name): ('arch', 'image', 'dtbs', 'upstream', 'series', 'config', 'defconfig', 'branch', 'loader')}} -def patches(name): - series = REPO / platform(name)['series'] - result = [] - for line in series.read_text().splitlines(): - line = line.split('#', 1)[0].strip() +def _kernel_path(path, what): + resolved = path.resolve() + if ROOT.resolve() not in resolved.parents: + raise ValueError(f'{what} leaves the kernel directory: {path}') + return resolved + + +def series_layers(name): + """Ordered (directory, scoped-only) entries, with one occurrence per layer.""" + described = platform(name) + series = REPO / described['series'] + result, seen = [], set() + for number, raw in enumerate(series.read_text().splitlines(), 1): + line = raw.split('#', 1)[0].strip() if not line: continue - layer = (ROOT / line).resolve() - if ROOT not in layer.parents or not layer.is_dir(): - raise ValueError(f'Invalid patch layer in {series}: {line}') - result += sorted(layer.rglob('*.patch')) + words = line.split() + scoped = words[0] == 'scoped' + if len(words) != (2 if scoped else 1): + raise ValueError(f'Invalid patch layer in {series}:{number}: {line}') + relative = words[-1] + if Path(relative).is_absolute() or '..' in Path(relative).parts: + raise ValueError(f'Invalid patch layer in {series}:{number}: {line}') + layer = _kernel_path(ROOT / relative, 'Patch layer') + if not layer.is_dir(): + raise ValueError(f'Invalid patch layer in {series}:{number}: {line}') + if layer in seen: + raise ValueError(f'Duplicate patch layer in {series}:{number}: {line}') + seen.add(layer) + result.append((layer, scoped)) + return result + + +def patch_platforms(patch): + """An adjacent PATCH.patch.toml restricts that patch to named platforms.""" + _kernel_path(patch, 'Patch') + if not patch.is_file(): + raise ValueError(f'Missing patch: {patch}') + sidecar = Path(str(patch) + '.toml') + if not sidecar.exists() and not sidecar.is_symlink(): + return None + _kernel_path(sidecar, 'Patch scope') + if not sidecar.is_file() or sidecar.is_symlink(): + raise ValueError(f'Patch scope must be a regular file: {sidecar}') + try: + value = tomllib.loads(sidecar.read_text()) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error: + raise ValueError(f'Invalid patch scope {sidecar}: {error}') from error + names = value.get('platforms') + if (set(value) != {'schema', 'platforms'} or type(value.get('schema')) is not int or + value['schema'] != 1 or not isinstance(names, list) or not names or + any(not isinstance(name, str) or not NAME.fullmatch(name) for name in names) or + len(set(names)) != len(names)): + raise ValueError(f'Invalid patch scope {sidecar}: expected schema 1 and distinct platform names') + for name in names: + if not (PLATFORMS / f'{name}.toml').is_file(): + raise ValueError(f'Unknown platform {name} in patch scope {sidecar}') + platform(name) + return frozenset(names) + + +def layer_patches(layer): + """Patches and validated scopes, also used to preflight new export layers.""" + _kernel_path(layer, 'Patch layer') + for sidecar in sorted(layer.rglob('*.patch.toml')): + _kernel_path(sidecar, 'Patch scope') + patch = Path(str(sidecar).removesuffix('.toml')) + if not patch.is_file(): + raise ValueError(f'Orphan patch scope: {sidecar}') + return [(patch, patch_platforms(patch)) for patch in sorted(layer.rglob('*.patch'))] + + +def patches(name): + """Select each patch once, preserving layer order and filename order.""" + selected = platform(name)['platform'] + result, seen = [], set() + for layer, scoped in series_layers(name): + for patch, scope in layer_patches(layer): + if (scope is None and scoped) or (scope is not None and selected not in scope): + continue + identity = patch.resolve() + if identity in seen: + raise ValueError(f'Patch selected by multiple layers: {patch}') + seen.add(identity) + result.append(patch) return result diff --git a/kernel/series/apple-armv7 b/kernel/series/apple-armv7 index e6b88fc7..fdd10d76 100644 --- a/kernel/series/apple-armv7 +++ b/kernel/series/apple-armv7 @@ -2,3 +2,4 @@ # to kernel/, on torvalds v6.12 (kernel/upstream-v6.12.lock). Every *.patch # below each directory is applied in path order. patches/import/cascadia +scoped patches/drivers/mtd diff --git a/platforms/apple-armv7.toml b/platforms/apple-armv7.toml index 61510ff2..8221e252 100644 --- a/platforms/apple-armv7.toml +++ b/platforms/apple-armv7.toml @@ -15,3 +15,19 @@ 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 selected installed-root profile loads its logical +# backend explicitly; other boards keep their built-in storage path. +storage_modules = ["apple-sftl"] +format = "lwvm" +devices = ["p105"] + +[recovery] +# The guarded host transport binds a fresh USB identity to each RAM boot. +identity_source = "host-usb" +usb_network = "ecm" +usb_modules = ["libcomposite", "usb_f_ecm", "usb_f_acm"] +manual_modules = ["apple_sftl"] +# Mapping recovery begins only after serial and authenticated SSH are ready. +storage_activation = "manual" diff --git a/runtime/recovery/device.py b/runtime/recovery/device.py index bb217220..a1954aa1 100755 --- a/runtime/recovery/device.py +++ b/runtime/recovery/device.py @@ -47,6 +47,9 @@ def bootstrap(request): if type(request.get('ecid')) is not int or any(request.get(key) != record[key] for key in ('profile', 'ecid', 'boot_id')): raise ValueError('SSH bootstrap identity differs from this recovery boot') + if record.get('identity_source') == 'host-usb' and any(type(request.get(key)) is not type(record[key]) or request.get(key) != record[key] for key in + ('identity_source', 'handoff_id', 'template_sha256', 'chip', 'board')): + raise ValueError('SSH bootstrap handoff differs from this recovery attempt') public = request.get('public_key', '') if not isinstance(public, str) or not re.fullmatch(KEY, public): raise ValueError('Expected one Ed25519 public key') diff --git a/runtime/recovery/identity.py b/runtime/recovery/identity.py index 7b622a3d..ca91d859 100644 --- a/runtime/recovery/identity.py +++ b/runtime/recovery/identity.py @@ -1,4 +1,4 @@ -"""Publish the live ADT ECID and kernel boot ID before exposing recovery USB.""" +"""Publish the live ADT identity or a descriptor-bound host USB boot handoff.""" import json import os from pathlib import Path @@ -8,15 +8,93 @@ from adt import validate +try: + from recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, object_json, + parameters, validate_policy) +except ModuleNotFoundError as error: + if error.name != 'recovery_identity': + raise + from orchard.recovery_identity import (HANDOFF_PARAMETER, POLICY_PARAMETER, object_json, + parameters, validate_policy) + RUN = Path('/run/orchard') BOOT_ID = Path('/proc/sys/kernel/random/boot_id') MTD = Path('/proc/mtd') DEV = Path('/dev') COMPATIBLE = Path('/proc/device-tree/compatible') +POLICY = Path('/etc/orchard/recovery-identity.json') +CMDLINE = Path('/proc/cmdline') +MAX_IDENTITY_BYTES = 2048 + + +def _bounded(path, maximum, *, protected=False): + try: + before = path.lstat() + if (not stat.S_ISREG(before.st_mode) or before.st_size > maximum or + (protected and (before.st_uid != 0 or before.st_mode & 0o022))): + raise ValueError('Identity input is not a protected regular file') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as stream: + after = os.fstat(stream.fileno()) + if (not stat.S_ISREG(after.st_mode) or + (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino) or + (protected and (after.st_uid != 0 or after.st_mode & 0o022))): + raise ValueError('Identity input changed while opening') + raw = stream.read(maximum + 1) + except OSError as error: + raise ValueError('Identity input is unavailable: ' + str(path)) from error + if len(raw) > maximum: + raise ValueError('Identity input exceeds its bound') + return raw + + +def _policy(): + if not POLICY.exists() and not POLICY.is_symlink(): + return None + return validate_policy(object_json(_bounded(POLICY, MAX_IDENTITY_BYTES, protected=True))) + + +def _compatible(policy): + raw = _bounded(COMPATIBLE, 4096) + if not raw.endswith(b'\0') or b'\0\0' in raw: + raise ValueError('Live compatible is incomplete') + try: + first = raw.split(b'\0')[0].decode('ascii') + except UnicodeError as error: + raise ValueError('Live compatible is invalid') from error + if first not in policy['compatibles']: + raise ValueError('Live compatible differs from the recovery identity policy') + return first + + +def _host_identity(policy): + _compatible(policy) + selected = parameters(_bounded(CMDLINE, MAX_IDENTITY_BYTES)) + if set(selected) != {POLICY_PARAMETER, HANDOFF_PARAMETER}: + raise ValueError('Recovery command line requires its policy and host USB handoff') + if selected[POLICY_PARAMETER] != policy: + raise ValueError('Recovery command-line policy differs from the staged descriptor') + handoff = selected[HANDOFF_PARAMETER] + if any(type(handoff[name]) is not type(policy[name]) or handoff[name] != policy[name] + for name in ('profile', 'chip', 'board')): + raise ValueError('Host USB handoff differs from the recovery identity policy') + handoff_id, template = handoff['handoff_id'], handoff['template_sha256'] + boot = uuid.UUID(BOOT_ID.read_text().strip()) + if not boot.int: + raise ValueError('Invalid live boot ID') + result = {'profile': policy['profile'], 'ecid': handoff['ecid'], 'boot_id': str(boot), + 'identity_source': 'host-usb', 'handoff_id': handoff_id, + 'template_sha256': template, 'chip': handoff['chip'], 'board': handoff['board']} + serial(result) + return result def profile(): - """The board's profile from its first devicetree compatible: \"apple,j71b\" -> \"j71b\".""" + """Use the staged descriptor for handoffs, or the existing ADT board spelling.""" + policy = _policy() + if policy is not None: + _compatible(policy) + return policy['profile'] board = COMPATIBLE.read_bytes().split(b'\0')[0].decode() if not re.fullmatch(r'apple,[a-z0-9]+', board): raise ValueError('Not an Apple board: ' + board) @@ -30,8 +108,17 @@ def cached(): info = path.lstat() if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: raise ValueError('Device identity record is not protected') - result = json.loads(path.read_text()) - if (result.get('profile') != profile() or type(result.get('ecid')) is not int or + policy = _policy() + if policy is not None: + result = object_json(_bounded(path, MAX_IDENTITY_BYTES, protected=True)) + expected = _host_identity(policy) + if (set(result) != set(expected) or + any(type(result[name]) is not type(value) or result[name] != value + for name, value in expected.items())): + raise ValueError('Device identity record differs from this boot handoff') + else: + result = json.loads(path.read_text()) + if policy is None and (result.get('profile') != profile() or type(result.get('ecid')) is not int or not 0 < result['ecid'] < 2**64 or result.get('boot_id') != str(uuid.UUID(BOOT_ID.read_text().strip()))): raise ValueError('Device identity record differs from this boot') @@ -41,21 +128,25 @@ def cached(): def initialize(): if cached() is not None: raise ValueError('Device identity was already published') - rows = re.findall(r'^mtd([0-9]+): ([0-9a-fA-F]+) [0-9a-fA-F]+ "adt"$', MTD.read_text(), re.M) - if len(rows) != 1: - raise ValueError('Expected one live ADT MTD entry') - number, size = rows[0][0], int(rows[0][1], 16) - if not 8 <= size <= 16 * 1024**2: - raise ValueError('Live ADT length outside bounds') - fd = os.open(DEV / ('mtd' + number), os.O_RDONLY | os.O_NOFOLLOW) - with os.fdopen(fd, 'rb') as stream: - if not stat.S_ISCHR(os.fstat(stream.fileno()).st_mode): - raise ValueError('Live ADT must be an MTD character device') - blob = stream.read(size) - if len(blob) != size: - raise ValueError('Live ADT read was truncated') - result = {'profile': profile(), 'ecid': validate(blob), - 'boot_id': str(uuid.UUID(BOOT_ID.read_text().strip()))} + policy = _policy() + if policy is not None: + result = _host_identity(policy) + else: + rows = re.findall(r'^mtd([0-9]+): ([0-9a-fA-F]+) [0-9a-fA-F]+ "adt"$', MTD.read_text(), re.M) + if len(rows) != 1: + raise ValueError('Expected one live ADT MTD entry') + number, size = rows[0][0], int(rows[0][1], 16) + if not 8 <= size <= 16 * 1024**2: + raise ValueError('Live ADT length outside bounds') + fd = os.open(DEV / ('mtd' + number), os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as stream: + if not stat.S_ISCHR(os.fstat(stream.fileno()).st_mode): + raise ValueError('Live ADT must be an MTD character device') + blob = stream.read(size) + if len(blob) != size: + raise ValueError('Live ADT read was truncated') + result = {'profile': profile(), 'ecid': validate(blob), + 'boot_id': str(uuid.UUID(BOOT_ID.read_text().strip()))} RUN.mkdir(mode=0o700, parents=True, exist_ok=True) if RUN.is_symlink(): raise ValueError('Linked runtime state refused') @@ -68,6 +159,14 @@ def initialize(): return result +def serial(record): + result = f'ECID:{record["ecid"]:016x} BOOT:{record["boot_id"]} BOARD:{record["profile"]}' + if record.get('identity_source') == 'host-usb': + result += ' HND:' + record['handoff_id'] + if len(result) > 126: + raise ValueError('Recovery identity exceeds the USB serial string limit') + return result + + if __name__ == '__main__': - record = initialize() - print(f'ECID:{record["ecid"]:016x} BOOT:{record["boot_id"]} BOARD:{record["profile"]}') + print(serial(initialize())) diff --git a/runtime/recovery/policy.py b/runtime/recovery/policy.py new file mode 100644 index 00000000..7a753692 --- /dev/null +++ b/runtime/recovery/policy.py @@ -0,0 +1,55 @@ +"""Read the descriptor-selected early recovery policy, with legacy defaults.""" +from pathlib import Path +import re +import stat +import os +import sys + +from recovery_identity import object_json, validate_policy + +CONFIG = Path('/etc/orchard/recovery.json') +IDENTITY = Path('/etc/orchard/recovery-identity.json') + + +def settings(): + if not CONFIG.exists() and not CONFIG.is_symlink(): + return {'usb_network': 'ncm', 'usb_modules': [], 'storage_activation': 'automatic'} + info = CONFIG.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or info.st_size > 2048: + raise ValueError('Recovery policy is not protected') + value = object_json(CONFIG.read_bytes()) + fields = {'schema', 'profile', 'usb_network', 'usb_modules', 'manual_modules', 'storage_activation'} + if (set(value) != fields or type(value['schema']) is not int or value['schema'] != 1 or + value['usb_network'] not in ('ncm', 'ecm') or value['storage_activation'] not in ('manual', 'automatic')): + raise ValueError('Invalid recovery runtime policy') + identity = validate_policy(object_json(IDENTITY.read_bytes())) + if value['profile'] != identity['profile']: + raise ValueError('Recovery policy profiles differ') + for key in ('usb_modules', 'manual_modules'): + names = value[key] + if (not isinstance(names, list) or any(not isinstance(name, str) or not re.fullmatch('[a-z0-9_-]+', name) for name in names) or + len(set(names)) != len(names)): + raise ValueError('Invalid recovery module policy') + normalized = lambda names: {name.replace('-', '_') for name in names} + if normalized(value['usb_modules']) & normalized(value['manual_modules']): + raise ValueError('A manual module cannot be loaded during USB setup') + return value + + +def loadable_modules(value, directory=None): + names = value['usb_modules'] + if not names: + return [] + directory = Path(directory) if directory is not None else Path('/lib/modules') / os.uname().release + builtins = {Path(line).stem.replace('-', '_') for line in + (directory / 'modules.builtin').read_text().splitlines() if line} + return [name for name in names if name.replace('-', '_') not in builtins] + + +if __name__ == '__main__': + value = settings() + selected = {'network': value['usb_network'], 'modules': ' '.join(loadable_modules(value)), + 'storage': value['storage_activation']} + if len(sys.argv) != 2 or sys.argv[1] not in selected: + raise SystemExit('Expected network, modules or storage') + print(selected[sys.argv[1]]) diff --git a/src/orchard/build.py b/src/orchard/build.py index b57711c5..eabfbb0e 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,82 @@ 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_lwvm(hardware=None, cache=None, *, device): + """Build the static LwVM mapper separately from the recovery tool contract.""" + hardware, cache = _build_context(hardware, cache, device) + from .image.platform import root_format + platform, _ = kernels.layout(hardware, device) + if root_format(hardware, platform, device=device) != 'lwvm': + raise ValueError('Selected device has no LwVM installed-root backend') + architecture = kernels.descriptor(hardware, device)['arch'] + inputs = ['boot/lwvm', 'boot/initramfs/recovery/Containerfile', + 'boot/initramfs/recovery/install-toolchain', + 'boot/initramfs/recovery/check-tools.py', 'src/orchard/buildenv.py'] + key = recipe_key(hardware, inputs) + key = hashlib.sha256((key + ':' + architecture).encode()).hexdigest() + def produce(stage): + command(hardware / 'boot/lwvm/build', '--arch', architecture, '--output', stage) + with component_lock(cache): + return cached_component(cache / 'components-v2', 'lwvm-' + architecture, + lambda: key, produce) / 'orchard-lwvm' + + +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 +682,24 @@ def prepare_components(hardware=None, cache=None, *, device, kernel=None, if kernel is not None and kernel_tree is not None: raise ValueError('Give a staged kernel (--kernel-dir) or a kernel tree (--kernel-tree), not both') tree = kernels.check_tree(hardware, device, kernel_tree) if kernel_tree is not None else None + described = kernels.descriptor(hardware, device) + if pongo is not None and described['loader'] == 'cascadia': + raise ValueError('Cascadia payloads use recovery-autogo firmware stages') + if loader is None and described['loader'] == 'cascadia': + loader = prepare_payload_loader(hardware, cache, device=device) if loader is None and not has_loader_patches(hardware): raise ValueError(f'Missing loader patches for {device}; add a loader recipe or supply --loader FILE') with component_lock(cache): - if pongo is None: + if pongo is None and described['loader'] == 'm1n1': pongo = _prepare_pongo(hardware, components) if kernel is None: kernel = _prepare_kernel(hardware, cache, device, tree) if busybox is None: - busybox = _prepare_tools(hardware, components) / 'busybox' + busybox = _prepare_tools(hardware, components, described['arch']) / 'busybox' if loader is None: # One m1n1 starts every Apple device; the board only picks its DTB. loader = _prepare_m1n1(hardware, components, 'apple') - return {name: Path(path).resolve() for name, path in + return {name: Path(path).resolve() if path is not None else None for name, path in dict(kernel=kernel, busybox=busybox, loader=loader, pongo=pongo).items()} diff --git a/src/orchard/buildenv.py b/src/orchard/buildenv.py index 332c7e04..a4c1d763 100644 --- a/src/orchard/buildenv.py +++ b/src/orchard/buildenv.py @@ -2,7 +2,7 @@ Recipes address inputs through ORCHARD_* path variables. Native mode provides real paths; local mode provides the corresponding container mount paths. Native -mode is not a sandbox and is only for trusted, disposable Linux ARM64 workers. +mode is not a sandbox and is only for trusted, disposable Linux ARM workers. """ import argparse import os @@ -17,8 +17,8 @@ def native(): if value not in ('0', '1'): raise ValueError('ORCHARD_BUILD_NATIVE must be 0 or 1') if value == '1': - if platform.system() != 'Linux' or platform.machine() not in ('aarch64', 'arm64'): - raise ValueError('Native builds require Linux ARM64') + if platform.system() != 'Linux' or platform.machine().lower() not in ('aarch64', 'arm64', 'armv7l', 'armv7', 'armhf'): + raise ValueError('Native builds require Linux ARM64 or ARMv7') if not os.environ.get('ORCHARD_BUILD_ENV_ID'): raise ValueError('Native builds require ORCHARD_BUILD_ENV_ID') return True @@ -26,20 +26,31 @@ def native(): # Podman's names for host machines it can build for. -HOST_ARCHES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64'} +HOST_ARCHES = {'x86_64': 'amd64', 'amd64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64', + 'armv7l': 'arm', 'armv7': 'arm', 'armhf': 'arm'} + + +def architecture_arguments(arch): + # OCI's ARM architecture needs a variant; plain arm can resolve to ARMv5. + return ['--platform', 'linux/arm/v7'] if arch == 'arm' else (['--arch', arch] if arch else []) + + +def check_native_architecture(arch): + if arch is not None and HOST_ARCHES.get(platform.machine().lower()) != arch: + raise ValueError(f'Native target {arch} differs from host architecture ' + f'{HOST_ARCHES.get(platform.machine().lower(), platform.machine())}') def build(image, containerfile, context, arch=None): if native(): - if arch not in (None, 'arm64'): - raise ValueError('Native builds require ARM64 inputs') + check_native_architecture(arch) print('Using provisioned native environment: ' + identity(image), flush=True) else: # Always name the architecture: recipes share base images such as # debian:bookworm, and an arm64 recipe re-points that local tag, which # a host-architecture build would otherwise silently inherit. arch = arch or HOST_ARCHES.get(platform.machine().lower()) - argv = ['podman', 'build'] + (['--arch', arch] if arch else []) + argv = ['podman', 'build', *architecture_arguments(arch)] subprocess.run([*argv, '-f', str(containerfile), '-t', image, str(context)], check=True) @@ -63,8 +74,7 @@ def translate(value, mounts): def run(image, command, mounts=(), *, env=None, cwd=None, root=False, network=False, arch=None, memory=None): """MEMORY: keep ORCHARD_WORK in memory of this size where a container runs it (e.g. '12g').""" if native(): - if arch not in (None, 'arm64'): - raise ValueError('Native builds require ARM64 inputs') + check_native_architecture(arch) with tempfile.TemporaryDirectory(prefix='orchard-recipe-') as temporary: work, tmp = Path(temporary) / 'work', Path(temporary) / 'tmp' work.mkdir() @@ -77,8 +87,7 @@ def run(image, command, mounts=(), *, env=None, cwd=None, root=False, network=Fa env={**os.environ, **paths, **extra}, check=True) else: argv = ['podman', 'run', '--rm', '--userns', 'keep-id:uid=0,gid=0' if root else 'keep-id'] - if arch: - argv += ['--arch', arch] + argv += architecture_arguments(arch) if not network: argv += ['--network', 'none'] if memory: diff --git a/src/orchard/command.py b/src/orchard/command.py index 0a49f93e..dc83ca93 100644 --- a/src/orchard/command.py +++ b/src/orchard/command.py @@ -138,8 +138,14 @@ def main(): '--platform or --device; update the distribution ' '(orchard.toml [release] selects-platform = true)') from orchard.build import resolve_kernel_tree + selected = {} + if distro.settings(root)['selects_device']: + from orchard.build import resolve_device + selected_device = resolve_device(root, args.device) + if selected_device is not None: + selected['device'] = selected_device index = distro.build(root, version=args.version, platforms=platforms, - kernel_tree=resolve_kernel_tree(root, args.kernel_tree)) + kernel_tree=resolve_kernel_tree(root, args.kernel_tree), **selected) commands = index.parent.parent / f'{index.parent.name}.commands.sh' if args.explain: print(commands.read_text(), end='') diff --git a/src/orchard/distro.py b/src/orchard/distro.py index 72afe53d..33813f74 100644 --- a/src/orchard/distro.py +++ b/src/orchard/distro.py @@ -61,13 +61,16 @@ def settings(root): if kind != 'release': raise ValueError(f'{root} has no orchard.toml with [release]') value = {**value, 'name': name} - unknown = set(value) - {'name', 'build', 'selects-platform'} + unknown = set(value) - {'name', 'build', 'selects-platform', 'selects-device'} if unknown: raise ValueError(f'{path}: unknown settings {", ".join(sorted(unknown))}; ' - 'expected name, build, selects-platform') + 'expected name, build, selects-platform, selects-device') selects = value.get('selects-platform', False) if not isinstance(selects, bool): raise ValueError(f'{path}: selects-platform must be true or false') + selects_device = value.get('selects-device', False) + if not isinstance(selects_device, bool) or (selects_device and not selects): + raise ValueError(f'{path}: selects-device requires selects-platform and must be true or false') name, build = value.get('name'), value.get('build') if not isinstance(name, str) or not _NAME.fullmatch(name): raise ValueError(f'{path}: name must be the distro name of its index, e.g. "omarchy-a"') @@ -79,7 +82,7 @@ def settings(root): raise ValueError(f'{path}: the build entry {build} is not a file') if entry.suffix != '.py' and not os.access(entry, os.X_OK): raise ValueError(f'{path}: the build entry {build} is not executable') - return {'name': name, 'build': entry, 'selects_platform': selects} + return {'name': name, 'build': entry, 'selects_platform': selects, 'selects_device': selects_device} def default_version(now=None): @@ -88,7 +91,7 @@ def default_version(now=None): return now.strftime('dev-%Y%m%d-%H%M%S') -def build(root, *, version=None, platforms=None, kernel_tree=None): +def build(root, *, version=None, platforms=None, kernel_tree=None, device=None): """Run ROOT's build entry into out/releases/VERSION, check the release, record it; returns its index. PLATFORMS: a list of platform names or 'all', passed as --platform to an @@ -118,6 +121,10 @@ def build(root, *, version=None, platforms=None, kernel_tree=None): elif distro['selects_platform']: raise ValueError(f'{distro["name"]} selects its platforms: choose with --platform, --device, ' 'orchard.local.toml or the attached board') + if device is not None: + if not distro['selects_device']: + raise ValueError('Distribution build entry does not accept --device') + argv += ['--device', device] print('+', ' '.join(argv), flush=True) environment = {**os.environ, 'ORCHARD_PYTHON': sys.executable, 'ORCHARD_CHECKOUT': str(ROOT)} tree = '' @@ -185,7 +192,8 @@ def run(root, *, build_first=True, version=None, yes=False, progress=None, kerne if board is None: raise ValueError('No described device is attached: connect it, then run again') platforms = [ram.resolve_platform(root, ROOT, device=board)] - index = build(root, version=version, platforms=platforms, kernel_tree=kernel_tree) + index = build(root, version=version, platforms=platforms, kernel_tree=kernel_tree, + **({'device': board} if settings(root)['selects_device'] else {})) else: index = latest(root) with operation_lock(): 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..d3c899de --- /dev/null +++ b/src/orchard/image/cascadia.py @@ -0,0 +1,490 @@ +#!/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 sys +import tomllib +import zlib + +if __package__: + from .. import recovery_identity as identity_protocol +else: # The distribution contract also runs this producer by file path. + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + import recovery_identity as identity_protocol + + +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) > identity_protocol.MAX_CMDLINE_BYTES: + 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 _policy_compatible(data, policy): + check_compatible(data, policy['compatibles']) + tree, _, _ = read_fdt(data) + raw = tree['properties']['compatible'] + if not raw.endswith(b'\0') or b'\0\0' in raw: + raise ValueError('Recovery DTB compatible list is incomplete') + if raw.split(b'\0')[0].decode('ascii') not in policy['compatibles']: + raise ValueError('First DTB compatible differs from the recovery identity policy') + + +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, identity_policy=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') + if identity_policy is not None: + policy = identity_protocol.validate_policy(identity_policy) + if identity_protocol.parameters(bootargs): + raise ValueError('Recovery template already contains identity parameters') + _policy_compatible(dtb, policy) + bootargs = bootargs + ' ' + identity_protocol.encode_parameter(identity_protocol.POLICY_PARAMETER, policy) + 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 _payload_parts(data): + """Validate a CB32 bundle before inspecting or binding its identity.""" + values = configuration(data) + if (values['image_size'] != len(data) or values['kernel_offset'] != 0x8000 or + data[:4] != bytes.fromhex('c0000cf1') or + values['crc32'] != zlib.crc32(data[CONFIG_OFFSET:CONFIG_OFFSET + CONFIG.size - 4])): + raise ValueError('Invalid Cascadia payload configuration') + kernel_at, dtb_at, dtb_size = (values[key] for key in ('kernel_offset', 'dtb_offset', 'dtb_size')) + initrd_at, initrd_size = values['initrd_offset'], values['initrd_size'] + if (not kernel_at + 0x3c <= dtb_at < dtb_at + dtb_size <= initrd_at or + initrd_at != aligned(dtb_at + dtb_size) or initrd_at + initrd_size != len(data) or + any(data[dtb_at + dtb_size:initrd_at])): + raise ValueError('Invalid Cascadia payload component ranges') + described = {'schema': 1, **{key: values[key] for key in PROFILE_FIELDS - {'schema'}}} + validate_profile(described) + if values['load_address'] + len(data) > values['initrd_address']: + raise ValueError('Cascadia payload overlaps its initramfs destination') + tree, _, _ = read_fdt(data[dtb_at:dtb_at + dtb_size]) + selected = next((child for child in tree['children'] if child['name'] == 'chosen'), None) + raw = selected['properties'].get('bootargs', b'') if selected else b'' + if not raw.endswith(b'\0') or b'\0' in raw[:-1]: + raise ValueError('Cascadia payload lacks one complete bootargs value') + try: + bootargs = raw[:-1].decode('utf-8') + except UnicodeError as error: + raise ValueError('Cascadia bootargs are not UTF-8') from error + return values, described, bootargs + + +def host_identity_policy(data): + """Return the declared host-bound recovery policy of an unbound template.""" + values, _, bootargs = _payload_parts(data) + if 'orchard.recovery-' not in bootargs: + return None + parameters = identity_protocol.parameters(bootargs) + if identity_protocol.HANDOFF_PARAMETER in parameters: + raise ValueError('A public recovery template already contains an identity handoff') + policy = parameters.get(identity_protocol.POLICY_PARAMETER) + if policy is None: + raise ValueError('Recovery identity parameters lack a policy') + start = values['dtb_offset'] + _policy_compatible(data[start:start + values['dtb_size']], policy) + return policy + + +def bind_host_identity(data, *, ecid, chip, board, profile, handoff_id, template_sha256): + """Bind a guarded host USB observation to one complete, revalidated bundle.""" + policy = host_identity_policy(data) + if policy is None: + raise ValueError('Payload does not declare host-bound recovery identity') + if (profile, chip, board) != (policy['profile'], policy['chip'], policy['board']): + raise ValueError('Selected device differs from the recovery identity policy') + if template_sha256 != digest(data): + raise ValueError('Recovery template digest differs before identity binding') + handoff = identity_protocol.validate_handoff({'schema': 1, 'source': 'host-usb', + 'profile': profile, 'ecid': ecid, 'chip': chip, 'board': board, + 'handoff_id': handoff_id, 'template_sha256': template_sha256}) + values, described, bootargs = _payload_parts(data) + loader = bytearray(data[:values['kernel_offset']]) + pristine = dict(values) + for key in ('dtb_offset', 'dtb_size', 'initrd_offset', 'initrd_size', 'image_size', 'crc32'): + pristine[key] = 0 + loader[CONFIG_OFFSET:CONFIG_OFFSET + CONFIG.size] = CONFIG.pack(*(pristine[key] for key in CONFIG_FIELDS)) + bootargs += ' ' + identity_protocol.encode_parameter(identity_protocol.HANDOFF_PARAMETER, handoff) + output, _ = assemble(bytes(loader), data[values['kernel_offset']:values['dtb_offset']], + data[values['dtb_offset']:values['dtb_offset'] + values['dtb_size']], + data[values['initrd_offset']:], described, bootargs) + _payload_parts(output) + return output + + +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('--identity-policy', type=Path, help='descriptor-selected public recovery identity policy') + 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, + identity_policy=identity_protocol.validate_policy(identity_protocol.object_json(args.identity_policy.read_bytes())) + if args.identity_policy else None) + except (ValueError, KeyError) as error: + parser.error(str(error)) + document['profile_sha256'] = digest(args.profile.read_bytes()) + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('xb') as output: + output.write(data) + with receipt.open('x') as output: + json.dump(document, output, indent=2, sort_keys=True) + output.write('\n') + print(f'Built {args.output}; component receipt: {receipt}') + + +if __name__ == '__main__': + main() diff --git a/src/orchard/image/elf.py b/src/orchard/image/elf.py index 48c6fc14..92a4723a 100644 --- a/src/orchard/image/elf.py +++ b/src/orchard/image/elf.py @@ -1,35 +1,109 @@ -"""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 require_static(data): + """Reject an interpreter or shared-library dependency in an initrd tool.""" + parsed = _parts(data) + if parsed is None: + raise ValueError('Expected a static ELF executable') + if any(kind == 3 for kind, _, _, _ in parsed[2]) or needed(data): + raise ValueError('Installed root helpers must be static ELF executables') + + 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..e5512252 100644 --- a/src/orchard/image/platform.py +++ b/src/orchard/image/platform.py @@ -17,12 +17,15 @@ import gzip import io import json +import re +import tomllib from pathlib import Path import stat import tarfile import tempfile from ..hardware import ROOT +from .elf import require_static from .integrity import sha from .runtime import checksums, module_files, regulatory_files @@ -49,7 +52,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 +60,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 +82,8 @@ 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, + lwvm=None, rescue_tools=None): """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 +95,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() @@ -101,11 +105,31 @@ def initrd(modules, release, busybox, *, hardware=None): wanted.append(name) pending.extend(depends[name]) directory = stat.S_IFDIR | 0o755 - entries = {name: (directory, b'') for name in ('bin', 'dev', 'proc', 'sys', 'etc', 'etc/orchard', 'newroot', + entries = {name: (directory, b'') for name in ('bin', 'dev', 'proc', 'sys', 'run', 'root', 'etc', 'etc/orchard', 'newroot', 'lib', 'lib/modules', 'lib/modules/' + release)} entries['init'] = (stat.S_IFREG | 0o755, (hardware / 'boot/initramfs/root/init').read_bytes()) + entries['bin/orchard-load-storage'] = (stat.S_IFREG | 0o755, + (hardware / 'boot/initramfs/root/load-storage').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 lwvm is not None: + if rescue_tools is None: + raise ValueError('LwVM installed root requires owner SSH recovery tools') + entries['bin/orchard-lwvm'] = (stat.S_IFREG | 0o755, Path(lwvm).read_bytes()) + entries['etc/passwd'] = (stat.S_IFREG | 0o644, b'root:x:0:0:root:/root:/bin/sh\n') + entries['etc/group'] = (stat.S_IFREG | 0o644, b'root:x:0:\n') + entries['etc/shadow'] = (stat.S_IFREG | 0o600, b'root:!:0:0:99999:7:::\n') + entries['etc/udhcpd.conf'] = (stat.S_IFREG | 0o644, + (hardware / 'boot/initramfs/recovery/udhcpd.conf').read_bytes()) + entries['bin/orchard-acm-rescue'] = (stat.S_IFREG | 0o755, + (hardware / 'boot/initramfs/nfs/acm-rescue').read_bytes()) + for name in ('dropbear', 'dropbearkey'): + entries['bin/' + name] = (stat.S_IFREG | 0o755, (Path(rescue_tools) / name).read_bytes()) + for source, target in (('authorize-key', 'recovery-authorize-key'), ('start-ssh', 'recovery-start-ssh')): + data = (hardware / 'boot/initramfs/recovery' / source).read_bytes() + data = data.replace(b'/usr/bin/dropbearkey', b'/run/orchard/rescue/dropbearkey') + data = data.replace(b'/usr/sbin/dropbear', b'/run/orchard/rescue/dropbear') + entries['bin/' + target] = (stat.S_IFREG | 0o755, data) dependency_lines = [] for name in sorted(wanted): target = f'lib/modules/{release}/{name}' @@ -121,14 +145,63 @@ def initrd(modules, release, busybox, *, hardware=None): return output.read_bytes() -def files(bundle, platform, board_dtbs, *, busybox, hardware=None): +def storage_modules(hardware, platform, *, device=None): + """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()) + selected = settings.get('root', {}).get('devices') + if selected is not None: + root_format(hardware, platform, device=device) + if device not in selected: + return () + 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 root_format(hardware, platform, *, device=None): + """The partition map used by this platform's installed-root provider.""" + 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()) + value = settings.get('root', {}).get('format', 'gpt') + if value not in ('gpt', 'lwvm'): + raise ValueError('Unsupported platform root.format: ' + str(value)) + selected = settings.get('root', {}).get('devices') + if selected is not None: + if (not isinstance(selected, list) or not selected or + any(not isinstance(name, str) or not re.fullmatch(r'[a-z0-9][a-z0-9_-]*', name) + for name in selected) or len(selected) != len(set(selected))): + raise ValueError('Platform root.devices must be distinct device profiles') + if set(selected) - set(boards(platform, hardware)): + raise ValueError('Platform root.devices names an unknown or different-platform device') + if device is None: + raise ValueError('Platform root.devices requires a selected device') + if device not in selected: + return 'gpt' + return value + + +def files(bundle, platform, board_dtbs, *, busybox, hardware=None, lwvm=None, rescue_tools=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) + choices = {storage_modules(hardware, platform, device=board) for board in board_dtbs} + if len(choices) != 1: + raise ValueError('Different early-root module sets require --device') + modules_to_load = next(iter(choices)) + result[base + 'initrd'] = (initrd(result, release, busybox, hardware=hardware, + storage_modules=modules_to_load, lwvm=lwvm, + rescue_tools=rescue_tools), 0o644) for dtb in sorted(set(board_dtbs.values())): path = bundle / 'dtbs' / (dtb + '.dtb') if path.is_symlink() or not path.is_file(): @@ -145,7 +218,8 @@ def files(bundle, platform, board_dtbs, *, busybox, hardware=None): raise ValueError(f'System defaults belong under /usr/lib or /usr/share/alsa/ucm2: {name}') 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()))} + 'boards': dict(sorted(board_dtbs.items())), '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 +240,53 @@ 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, device=None, lwvm=None, rescue_tools=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 device is not None: + if device not in board_dtbs: + raise ValueError(f'Device {device} does not belong to platform {platform}') + board_dtbs = {device: board_dtbs[device]} + 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}') + root_formats = {root_format(hardware or ROOT, platform, device=board) for board in board_dtbs} + if len(root_formats) != 1: + raise ValueError('Different installed-root backends require --device') + selected_root_format = next(iter(root_formats)) 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) - release, layer = files(bundle, platform, board_dtbs, busybox=busybox, hardware=hardware) + 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') + if selected_root_format == 'lwvm': + from ..build import prepare_lwvm, prepare_tools + from ..kernel import descriptor + board = device or sorted(board_dtbs)[0] + lwvm = Path(lwvm) if lwvm is not None else prepare_lwvm(hardware, cache, device=board) + rescue_tools = Path(rescue_tools) if rescue_tools is not None else prepare_tools(hardware, cache, device=board) + from .elf import architecture as elf_architecture + expected = descriptor(hardware or ROOT, board)['arch'] + for binary in (busybox, lwvm, rescue_tools / 'dropbear', rescue_tools / 'dropbearkey'): + if elf_architecture(binary.read_bytes()) != expected: + raise ValueError('Installed root helper differs from platform architecture') + require_static(binary.read_bytes()) + release, layer = files(bundle, platform, board_dtbs, busybox=busybox, hardware=hardware, + lwvm=lwvm, rescue_tools=rescue_tools) 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)} + 'kernel_release': release, 'boards': sorted(board_dtbs), + 'architecture': json.loads(layer['usr/lib/orchard/platform.json'][0])['architecture']} def main(): @@ -189,10 +295,17 @@ def main(): parser.add_argument('--output', type=Path, required=True, help='directory for the layer') 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('--lwvm', type=Path, help='static orchard-lwvm mapper for an LwVM platform') + parser.add_argument('--rescue-tools', type=Path, help='directory containing static Dropbear tools for early rescue') parser.add_argument('--receipt', type=Path, help="also write the layer's receipt (JSON) here") + parser.add_argument('--device', help='restrict the platform layer to this board') + 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, device=args.device, + lwvm=args.lwvm, rescue_tools=args.rescue_tools) if args.receipt: args.receipt.write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') print(json.dumps(receipt, indent=2)) diff --git a/src/orchard/image/platforms_index.py b/src/orchard/image/platforms_index.py index 04074db2..db3202d3 100644 --- a/src/orchard/image/platforms_index.py +++ b/src/orchard/image/platforms_index.py @@ -17,6 +17,7 @@ import tomllib from . import platform +from .elf import architecture, require_static from .integrity import sha from .publish import toml @@ -46,7 +47,7 @@ def receipts(document): return [*document['loaders'].values(), document['install_tools'], *document['platforms'].values()] -def build(directory, *, kernels, tools, commit, loaders=None): +def build(directory, *, kernels, tools, commit, loaders=None, platform_inputs=None): """Write the layers of KERNELS ({platform: bundle}), the install tools and platforms.toml into DIRECTORY. TOOLS is the directory of static tools. LOADERS ({'pongo': Path, 'm1n1': Path}) are copied @@ -54,8 +55,11 @@ def build(directory, *, kernels, tools, commit, loaders=None): """ for name, path in (loaders or {}).items(): shutil.copyfile(path, directory / f'{name}.bin') - layers = {name: platform.build(name, directory, bundle=bundle, busybox=tools / 'busybox') - for name, bundle in kernels.items()} + layers = {} + for name, bundle in kernels.items(): + options = {'bundle': bundle, 'busybox': tools / 'busybox'} + options.update((platform_inputs or {}).get(name, {})) + layers[name] = platform.build(name, directory, **options) write_install_tools(tools, directory / 'install-tools.tar.gz') document = {'schema': 1, 'kind': 'orchard.platforms', 'commit': commit, 'loaders': {path.stem: receipt(path) for path in (directory / name for name in LOADERS)}, @@ -65,15 +69,113 @@ def build(directory, *, kernels, tools, commit, loaders=None): return document +def input_plan(inputs): + """Validate every selected input before the index writes any output. + + The block-image installer currently publishes one m1n1 and one native + install-tools archive. Cascadia's ARMv7 launch contract remains separate. + """ + from ..hardware import ROOT + from ..kernel import descriptor + from ..ram import boards_by_platform + + if not isinstance(inputs, dict) or inputs.get('schema', 1) != 1: + raise ValueError('Expected schema-1 Orchard inputs') + rows = inputs.get('platforms') + if not isinstance(rows, dict) or not rows: + raise ValueError('Orchard inputs name no platforms') + known = boards_by_platform(ROOT) + if set(rows) - set(known): + raise ValueError('Unknown platform in Orchard inputs') + if inputs.get('architecture', 'arm64') != 'arm64': + raise ValueError('The platform index requires the ARM64 m1n1 install contract; use the ARMv7 platform producer directly') + shared = inputs.get('shared', {}) + if not isinstance(shared, dict): + raise ValueError('Invalid shared Orchard inputs') + + def regular(value, role): + if not isinstance(value, str) or not value or not Path(value).is_absolute(): + raise ValueError(role + ' needs an absolute file path') + path = Path(value) + if path.is_symlink() or not path.is_file(): + raise ValueError(role + ' needs a regular file') + return path + + def executable(value, role): + path = regular(value, role) + data = path.read_bytes() + if architecture(data) != 'arm64': + raise ValueError(role + ' differs from the ARM64 root architecture') + require_static(data) + return path + + kernels, options, tool_sets, loaders = {}, {}, [], [] + for name, row in rows.items(): + if not isinstance(row, dict): + raise ValueError('Invalid platform input record: ' + name) + described = descriptor(ROOT, name) + if (row.get('architecture', described['arch']) != 'arm64' or described['arch'] != 'arm64' or + row.get('image', described['image']) != described['image']): + raise ValueError('Platform ' + name + ' differs from the ARM64 root architecture or kernel image') + board_dtbs = row.get('boards') + all_boards = known[name] + if (not isinstance(board_dtbs, dict) or not board_dtbs or + any(board not in all_boards or dtb != all_boards[board] + for board, dtb in board_dtbs.items())): + raise ValueError('Platform ' + name + ' has an invalid board-to-DTB selection') + if board_dtbs == all_boards: + device = None + elif len(board_dtbs) == 1: + device = next(iter(board_dtbs)) + else: + raise ValueError('A platform layer requires one selected board or every board') + formats = {platform.root_format(ROOT, name, device=board) for board in board_dtbs} + if len(formats) != 1 or row.get('root_format', 'gpt') != next(iter(formats)): + raise ValueError('Platform ' + name + ' root format differs from its selected boards') + root_format = next(iter(formats)) + loader = row['loader'] if 'loader' in row else {'kind': 'm1n1', 'file': shared.get('m1n1')} + if not isinstance(loader, dict) or loader.get('kind') != 'm1n1': + raise ValueError('The ARM64 platform index requires an m1n1 loader') + loaders.append(regular(loader.get('file'), name + ' loader')) + tools = row['tools'] if 'tools' in row else shared.get('tools') + if not isinstance(tools, dict): + raise ValueError('Platform ' + name + ' has no native tool record') + selected_tools = {tool: executable(tools.get(tool), name + ' ' + tool) + for tool in (*INSTALL_TOOLS, 'busybox')} + layer = {'busybox': selected_tools['busybox'], 'root_architecture': 'arm64', 'device': device} + if root_format == 'lwvm': + mapper = executable(tools.get('lwvm'), name + ' LwVM mapper') + dropbearkey = executable(tools.get('dropbearkey'), name + ' dropbearkey') + if selected_tools['dropbear'].parent != dropbearkey.parent: + raise ValueError('The LwVM rescue tools must share a directory') + layer.update(lwvm=mapper, rescue_tools=dropbearkey.parent) + elif tools.get('lwvm') is not None: + raise ValueError('A GPT platform cannot supply an LwVM mapper') + bundle = row.get('kernel') + if (not isinstance(bundle, str) or not Path(bundle).is_absolute() or + Path(bundle).is_symlink() or not Path(bundle).is_dir()): + raise ValueError('Platform ' + name + ' needs a staged kernel directory') + kernels[name], options[name] = Path(bundle), layer + tool_sets.append(selected_tools) + if any(sha(loader) != sha(loaders[0]) for loader in loaders[1:]): + raise ValueError('One platform index requires the same m1n1 loader for every selected platform') + for tools in tool_sets[1:]: + if any(sha(tools[name]) != sha(tool_sets[0][name]) for name in INSTALL_TOOLS): + raise ValueError('One platform index requires the same install tools for every selected platform') + return kernels, options, tool_sets[0], {'pongo': regular(shared.get('pongo'), 'Pongo'), 'm1n1': loaders[0]} + + def from_inputs(directory, inputs): - """platforms.toml and its files, from a document of orchard.inputs: this build's own kernels.""" + """platforms.toml and its files from the exact selected Orchard input records.""" + kernels, options, selected_tools, loaders = input_plan(inputs) with tempfile.TemporaryDirectory() as temporary: tools = Path(temporary) - for name, path in inputs['shared']['tools'].items(): + for name, path in selected_tools.items(): shutil.copyfile(path, tools / name) - return build(directory, kernels={name: Path(row['kernel']) for name, row in inputs['platforms'].items()}, + Path(directory).mkdir(parents=True, exist_ok=True) + return build(directory, kernels=kernels, tools=tools, commit=(inputs.get('orchard') or {}).get('commit') or 'unknown', - loaders={name: inputs['shared'][name] for name in ('pongo', 'm1n1')}) + loaders=loaders, platform_inputs=options) def fetch(url, path, entry=None): @@ -121,14 +223,15 @@ def main(): 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') args = parser.parse_args() - args.directory.mkdir(parents=True, exist_ok=True) if args.release: + args.directory.mkdir(parents=True, exist_ok=True) from_release(args.directory, args.release, args.platform) 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') + args.directory.mkdir(parents=True, exist_ok=True) build(args.directory, kernels={name: args.kernels / name / 'components/kernel' for name in platform.platforms()}, tools=args.tools, commit=args.commit) diff --git a/src/orchard/image/prepare_runtime.py b/src/orchard/image/prepare_runtime.py index 9317506a..7bc79ac1 100644 --- a/src/orchard/image/prepare_runtime.py +++ b/src/orchard/image/prepare_runtime.py @@ -10,7 +10,7 @@ from .runtime import files as runtime_files, recovery_files from .integrity import sha -from .elf import needed +from .elf import needed, architecture as elf_architecture from ..hardware import ROOT as PROJECT HERE = PROJECT / 'boot/initramfs/recovery' @@ -22,11 +22,18 @@ def copy(source, target, mode): target.chmod(mode) -def stage_runtime(base, receipt, root): +def stage_runtime(base, receipt, root, architecture='arm64'): spec = json.loads(receipt.read_text()) + root_architecture = spec.get('architecture', architecture) + if not isinstance(root_architecture, str): + raise ValueError('Python source root architecture is invalid') + root_architecture = {'aarch64': 'arm64', 'arm64': 'arm64', + 'armv7h': 'arm', 'armhf': 'arm', 'arm': 'arm'}.get(root_architecture) + if root_architecture != architecture: + raise ValueError('Python source root architecture differs from recovery') if base.stat().st_size != spec['bytes'] or sha(base) != spec['sha256']: raise ValueError('Python source root differs from receipt') - libraries = re.compile(r'usr/lib/(?:ld-linux-aarch64|lib(?:python[0-9.]+|c|m|z|zstd|bz2|lzma|ffi|ssl|crypto|readline|ncursesw|tinfo|uuid|expat|gcc_s|mpdec))[-.].*') + libraries = re.compile(r'usr/lib/(?:ld-linux-(?:aarch64|armhf)|lib(?:python[0-9.]+|c|m|z|zstd|bz2|lzma|ffi|ssl|crypto|readline|ncursesw|tinfo|uuid|expat|gcc_s|mpdec))[-.].*') with tarfile.open(base, 'r|gz') as archive: for member in archive: name = member.name.removeprefix('./') @@ -74,7 +81,10 @@ def stage_runtime(base, receipt, root): required = set() for path in (root / 'usr').rglob('*'): if path.is_file() and not path.is_symlink(): - required.update(needed(path.read_bytes())) + data = path.read_bytes() + if data.startswith(b'\x7fELF') and elf_architecture(data) != architecture: + raise ValueError('Recovery contains an executable for another architecture: ' + str(path)) + required.update(needed(data)) missing = set() for name in required: path = root / 'usr/lib' / name @@ -112,12 +122,38 @@ def stage_runtime(base, receipt, root): raise ValueError('Python runtime dependency closure did not converge') +def check_recovery_modules(root, release, recovery): + """Manual storage must stay modular; declared USB dependencies must exist.""" + normalized = lambda names: {name.replace('-', '_') for name in names} + if normalized(recovery['runtime']['usb_modules']) & normalized(recovery['runtime']['manual_modules']): + raise ValueError('A manual module cannot be loaded during USB setup') + directory = root / 'usr/lib/modules' / release + builtins = {Path(name).stem.replace('-', '_') for name in + (directory / 'modules.builtin').read_text().splitlines() if name} + available = {path.stem.replace('-', '_') for path in directory.rglob('*.ko')} + for name in recovery['runtime']['manual_modules']: + if name.replace('-', '_') in builtins: + raise ValueError('Manual recovery module is built in: ' + name) + for name in recovery['runtime']['usb_modules']: + if name.replace('-', '_') not in builtins | available: + raise ValueError('Recovery USB module is unavailable: ' + name) + + def main(): parser = argparse.ArgumentParser(description=__doc__) for name in ('busybox', 'dropbear', 'dropbearkey', 'wpa-supplicant', 'wpa-cli', 'iw', 'base', 'base-receipt', 'modules-dir', 'root-receipt', 'output'): parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--architecture', choices=('arm', 'arm64'), default='arm64') + parser.add_argument('--device', help='stage the selected board recovery policy') args = parser.parse_args() + recovery = None + if args.device: + from ..recovery_policy import settings + from ..kernel import descriptor + if descriptor(PROJECT, args.device)['arch'] != args.architecture: + parser.error('Recovery board architecture differs from the runtime') + recovery = settings(args.device) root = args.output if root.exists(): parser.error('Choose a new staging directory') @@ -129,13 +165,18 @@ def main(): (args.wpa_supplicant, 'usr/sbin/wpa_supplicant'), (args.wpa_cli, 'usr/bin/wpa_cli'), (args.iw, 'usr/sbin/iw')): copy(source, root / name, 0o755) - stage_runtime(args.base, args.base_receipt, root) + for source in (args.busybox, args.dropbear, args.dropbearkey, args.wpa_supplicant, args.wpa_cli, args.iw): + if elf_architecture(source.read_bytes()) != args.architecture: + raise ValueError('Recovery tool architecture differs: ' + str(source)) + stage_runtime(args.base, args.base_receipt, root, args.architecture) kernel_release, public = runtime_files(args.modules_dir) for name, (data, mode) in {**public, **recovery_files()}.items(): path = root / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) path.chmod(mode) + if recovery is not None: + check_recovery_modules(root, kernel_release, recovery) # The release this recovery belongs to; `orchard-device status` reports it. # Root images carry no kernel: the release's platform layer of the same # kernel bundle does, so the recovery and an installed root match. @@ -151,6 +192,17 @@ def main(): (root / 'etc/group').write_text('root:x:0:\n') (root / 'etc/shadow').write_text('root:!:0:0:99999:7:::\n') (root / 'etc/shadow').chmod(0o600) + if recovery is not None: + for name, value in (('recovery-identity.json', recovery['identity']), ('recovery.json', recovery['runtime'])): + path = root / 'etc/orchard' / name + path.write_text(json.dumps(value, sort_keys=True) + '\n') + path.chmod(0o644) + manual = recovery['runtime']['manual_modules'] + if manual: + blacklist = root / 'etc/modprobe.d/orchard-recovery.conf' + blacklist.parent.mkdir(parents=True, exist_ok=True) + blacklist.write_text(''.join('blacklist ' + name + '\n' for name in manual)) + blacklist.chmod(0o644) print('Public recovery staged:', root) diff --git a/src/orchard/image/recovery.py b/src/orchard/image/recovery.py index 7c2ee440..70d84a01 100644 --- a/src/orchard/image/recovery.py +++ b/src/orchard/image/recovery.py @@ -19,10 +19,40 @@ def main(): parser.add_argument('--dtb', type=Path, action='append', required=True, help='the DTB of each device this recovery boots; repeat') parser.add_argument('--logo', type=Path, help="the distribution's m1n1 logo payload (see assemble.py)") + parser.add_argument('--platform', default='apple-16k', help='kernel platform and payload format') + parser.add_argument('--device', help='the board whose Cascadia handoff profile to use') diagnostics = parser.add_mutually_exclusive_group() diagnostics.add_argument('--verbose', action='store_true', help='enable visible Linux framebuffer console and kernel diagnostics') args = parser.parse_args() + from ..kernel import descriptor + from .platform import boards + described = descriptor(ROOT, args.platform) + profile = None + selected = args.device + if selected: + from ..hardware import device as described_device + if described_device(selected)['kernel']['platform'] != described['platform']: + parser.error('Recovery board does not belong to the selected platform') + if described['loader'] == 'cascadia': + if len(args.dtb) != 1 or args.logo is not None: + parser.error('Cascadia recovery takes one board DTB and no m1n1 logo') + available = boards(described['platform']) + selected = args.device + if selected is None: + candidates = [board for board, dtb in available.items() if args.dtb[0].stem == dtb] + if len(candidates) != 1: + parser.error('Select the Cascadia board with --device') + selected = candidates[0] + if selected not in available or args.dtb[0].stem != available[selected]: + parser.error('The Cascadia board and supplied DTB differ') + from ..hardware import payload_profile, device as described_device + from .cascadia import check_compatible + try: + profile = payload_profile(selected, 'cascadia', ROOT) + check_compatible(args.dtb[0].read_bytes(), described_device(selected, ROOT)['device']['dt_compatible']) + except ValueError as error: + parser.error(str(error)) manifest = 'boot-verbose.json' if args.verbose else 'boot.json' if args.output.exists(): parser.error('choose a new output path') @@ -39,11 +69,17 @@ def main(): '--wpa-cli', str(args.wpa_cli), '--iw', str(args.iw), '--base', str(args.base), '--base-receipt', str(args.base_receipt), '--modules-dir', str(args.modules_dir), '--root-receipt', str(args.root_receipt), + '--architecture', described['arch'], + *(['--device', selected] if selected else []), '--output', str(stage / 'root')], check=True) subprocess.run([sys.executable, '-m', 'orchard.image.initramfs', '--root', str(stage / 'root'), '--output', str(initramfs)], check=True) - subprocess.run([sys.executable, '-m', 'orchard.image.assemble', + assembly = 'orchard.image.cascadia' if profile is not None else 'orchard.image.assemble' + subprocess.run([sys.executable, '-m', assembly, + *(['--profile', str(profile)] if profile is not None else []), + *(['--identity-policy', str(stage / 'root/etc/orchard/recovery-identity.json')] + if (stage / 'root/etc/orchard/recovery-identity.json').is_file() else []), '--manifest', str(HERE / manifest), '--loader', str(args.loader), *(f'--dtb={dtb}' for dtb in args.dtb), *(['--logo', str(args.logo)] if args.logo else []), diff --git a/src/orchard/image/runtime.py b/src/orchard/image/runtime.py index 577717ae..595f3bec 100644 --- a/src/orchard/image/runtime.py +++ b/src/orchard/image/runtime.py @@ -63,7 +63,8 @@ def recovery_files(): prefix = 'usr/libexec/orchard/' source = PROJECT / 'runtime/recovery' result = {prefix + name: ((source / name).read_bytes(), 0o755) - for name in ('device.py', 'identity.py', 'storage-fence', 'start.py')} + for name in ('device.py', 'identity.py', 'policy.py', 'storage-fence', 'start.py')} + result[prefix + 'recovery_identity.py'] = ((PROJECT / 'src/orchard/recovery_identity.py').read_bytes(), 0o644) result[prefix + 'adt.py'] = ((PROJECT / 'src/orchard/apple/adt.py').read_bytes(), 0o644) result['usr/bin/orchard-device'] = (b'#!/bin/sh\nexec /usr/bin/python3 /usr/libexec/orchard/device.py "$@"\n', 0o755) return result diff --git a/src/orchard/inputs.py b/src/orchard/inputs.py index 3d84b442..f87246db 100644 --- a/src/orchard/inputs.py +++ b/src/orchard/inputs.py @@ -32,30 +32,58 @@ 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): - """The inputs document for PLATFORMS (default: every arm64 platform). +def resolve(platforms=None, explicit=None, *, hardware=None, cache=None, shared=None, kernel_tree=None, + platform_loaders=None, platform_tools=None, platform_lwvm=None, device=None): + """Resolve one architecture's inputs; default to the existing arm64 platforms. - 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. + Per-platform loader/tool overrides keep offline builds independent of + orchard's cache. A root build must choose platforms of one architecture. """ - from .build import prepare_shared + from .build import prepare_shared, prepare_payload_loader, prepare_tools, prepare_lwvm from .hardware import ROOT, revision from .image import platform + from .kernel import descriptor, layout hardware = Path(hardware or ROOT) explicit = explicit or {} - known = platform.platforms(hardware) - selected = list(platforms or known) - unknown = sorted(set(selected) - set(known)) + sorted(set(explicit) - set(selected)) + platform_loaders, platform_tools = platform_loaders or {}, platform_tools or {} + platform_lwvm = platform_lwvm or {} + known = platform.platforms(hardware, architecture=None) + if device is not None: + selected_platform, _ = layout(hardware, device) + if platforms and list(platforms) != [selected_platform]: + raise ValueError('Selected device does not match the requested platform') + selected = [selected_platform] + else: + selected = list(platforms or platform.platforms(hardware)) + unknown = (set(selected) - set(known)) | ((set(explicit) | set(platform_loaders) | + set(platform_tools) | set(platform_lwvm)) - set(selected)) if unknown: - raise ValueError(f'Unknown or unselected platform {", ".join(unknown)}; known: {", ".join(known)}') + raise ValueError(f'Unknown or unselected platform {", ".join(sorted(unknown))}; known: {", ".join(known)}') + descriptions = {name: descriptor(hardware, name) for name in selected} + architectures = {description['arch'] for description in descriptions.values()} + if len(architectures) != 1: + raise ValueError('A distribution root requires platforms of one architecture') + architecture = next(iter(architectures)) + board_sets = {name: platform.boards(name, hardware) for name in selected} + if device is not None: + board_sets[selected[0]] = {device: board_sets[selected[0]][device]} + for name, description in descriptions.items(): + if description['loader'] == 'cascadia' and len(board_sets[name]) != 1: + raise ValueError('Cascadia recovery requires --device to select one board profile') given = {key: Path(value) for key, value in (shared or {}).items() if value is not None} - found = {} if len(given) == 3 else prepare_shared(hardware, cache) - m1n1 = given.get('m1n1') or found['loader'] - pongo = given.get('pongo') or found['pongo'] - tools = given.get('tools') or Path(found['tools']) - for path in (m1n1, pongo, *(tools / name for name in TOOLS)): - if not Path(path).is_file(): - raise ValueError(f'Missing component {path}') + shared_document = {} + if architecture == 'arm64': + found = {} if len(given) == 3 else prepare_shared(hardware, cache) + m1n1 = given.get('m1n1') or found['loader'] + pongo = given.get('pongo') or found['pongo'] + tools = given.get('tools') or Path(found['tools']) + shared_document = {'m1n1': str(Path(m1n1).resolve()), 'pongo': str(Path(pongo).resolve()), + 'tools': {name: str((tools / name).resolve()) for name in TOOLS}} + for path in (m1n1, pongo, *(tools / name for name in TOOLS)): + if not Path(path).is_file(): + raise ValueError(f'Missing component {path}') + elif given: + raise ValueError('ARMv7 inputs use --platform-loader and --platform-tools overrides') try: orchard = revision() except ValueError: @@ -65,29 +93,60 @@ def bundle(name): 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], + return prepare_kernel(hardware, cache, device=sorted(board_sets[name])[0], kernel_tree=kernel_tree) return platform.kernel(name, hardware=hardware, cache=cache) - return { - 'schema': SCHEMA, - 'orchard': orchard, - 'platforms': {name: {'kernel': str(Path(bundle(name)).resolve()), - 'boards': platform.boards(name, hardware)} - for name in selected}, - 'shared': {'m1n1': str(Path(m1n1).resolve()), 'pongo': str(Path(pongo).resolve()), - 'tools': {name: str((tools / name).resolve()) for name in TOOLS}}, - } + records = {} + for name in selected: + description = descriptions[name] + board = sorted(board_sets[name])[0] + if architecture == 'arm64': + loader = platform_loaders.get(name) or m1n1 + target_tools = platform_tools.get(name) or tools + else: + loader = platform_loaders.get(name) or prepare_payload_loader(hardware, cache, device=board) + target_tools = platform_tools.get(name) or prepare_tools(hardware, cache, device=board) + target_tools = Path(target_tools) + for path in (loader, *(target_tools / tool for tool in TOOLS)): + if not Path(path).is_file(): + raise ValueError(f'Missing component {path}') + records[name] = {'kernel': str(Path(bundle(name)).resolve()), + 'boards': board_sets[name], + 'architecture': description['arch'], 'image': description['image'], + 'root_format': platform.root_format(hardware, name, device=board), + 'loader': {'kind': description['loader'], 'file': str(Path(loader).resolve())}, + 'tools': {tool: str((target_tools / tool).resolve()) for tool in TOOLS}} + if records[name]['root_format'] == 'lwvm': + helper = Path(platform_lwvm.get(name) or prepare_lwvm(hardware, cache, device=board)) + if not helper.is_file() or helper.is_symlink(): + raise ValueError(f'Missing regular LwVM helper {helper}') + from .image import elf + if elf.architecture(helper.read_bytes()) != architecture: + raise ValueError('LwVM helper differs from the input architecture') + elf.require_static(helper.read_bytes()) + records[name]['tools']['lwvm'] = str(helper.resolve()) + elif name in platform_lwvm: + raise ValueError('--platform-lwvm requires an LwVM platform') + return {'schema': SCHEMA, 'orchard': orchard, 'architecture': architecture, + 'platforms': records, 'shared': shared_document} 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') + parser.add_argument('--device', help='restrict inputs to this board and its loader profile') parser.add_argument('--platform', action='append', help='a platform to build for; repeat (default: every one)') parser.add_argument('--kernel-dir', action='append', default=[], metavar='PLATFORM=DIRECTORY', help="a platform's staged kernel bundle instead of orchard's") 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('--platform-loader', action='append', default=[], metavar='PLATFORM=FILE', + help='an explicit payload loader for one platform') + parser.add_argument('--platform-tools', action='append', default=[], metavar='PLATFORM=DIRECTORY', + help='an explicit static tool directory for one platform') + parser.add_argument('--platform-lwvm', action='append', default=[], metavar='PLATFORM=FILE', + help='an explicit static LwVM mapper for one LwVM platform') 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 +156,17 @@ def main(): if not separator: parser.error('--kernel-dir takes PLATFORM=DIRECTORY') explicit[name] = Path(path) + def overrides(values, option): + result = {} + for value in values: + name, separator, path = value.partition('=') + if not separator or not name or not path or name in result: + parser.error(option + ' takes distinct PLATFORM=PATH values') + result[name] = Path(path) + return result + platform_loaders = overrides(args.platform_loader, '--platform-loader') + platform_tools = overrides(args.platform_tools, '--platform-tools') + platform_lwvm = overrides(args.platform_lwvm, '--platform-lwvm') try: import os tree = args.kernel_tree or (Path(os.environ['ORCHARD_KERNEL_TREE']) if os.environ.get('ORCHARD_KERNEL_TREE') @@ -105,7 +175,9 @@ 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, + platform_loaders=platform_loaders, platform_tools=platform_tools, + platform_lwvm=platform_lwvm, device=args.device) finally: steps = explain.stop() except ValueError as error: diff --git a/src/orchard/project.py b/src/orchard/project.py index d084147a..3bcf8343 100644 --- a/src/orchard/project.py +++ b/src/orchard/project.py @@ -10,6 +10,7 @@ [release] build = "tools/build.py" # relative to this file selects-platform = true # optional: the script takes --platform + selects-device = true # optional: preserve the chosen board as --device The file says what the project builds, never which board it is for; a developer's choices (platform, device, kernel tree) are arguments or diff --git a/src/orchard/published.py b/src/orchard/published.py index f383d789..ac44d7b0 100644 --- a/src/orchard/published.py +++ b/src/orchard/published.py @@ -41,11 +41,12 @@ def series(*args): return subprocess.check_output([sys.executable, str(hardware / 'kernel/series.py'), *args], text=True) described = json.loads(series('--platform', platform)) patches = [str(Path(line).resolve().relative_to(hardware.resolve())) for line in series(platform).splitlines()] + scopes = [path + '.toml' for path in patches if (hardware / (path + '.toml')).is_file()] return { 'platform': platform, 'build': {key: described[key] for key in ('arch', 'image', 'dtbs')}, 'recipes': recipe_entries(hardware, [described['upstream'], described['series'], described['config'], - 'kernel/Containerfile', 'kernel/install-toolchain', *patches]), + 'kernel/Containerfile', 'kernel/install-toolchain', *patches, *scopes]), 'producer': KERNEL_PRODUCER, } 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/src/orchard/recovery_identity.py b/src/orchard/recovery_identity.py new file mode 100644 index 00000000..91c56bb0 --- /dev/null +++ b/src/orchard/recovery_identity.py @@ -0,0 +1,124 @@ +"""Bounded host USB identity parameters shared by payloads and RAM recovery.""" +import base64 +import binascii +import json +import re + +POLICY_PARAMETER = 'orchard.recovery-policy' +HANDOFF_PARAMETER = 'orchard.recovery-handoff' +MAX_ENCODED_BYTES = 2048 +MAX_CMDLINE_BYTES = 1023 +POLICY_FIELDS = frozenset(('schema', 'source', 'profile', 'compatibles', 'chip', 'board')) +HANDOFF_FIELDS = frozenset(('schema', 'source', 'profile', 'ecid', 'chip', 'board', + 'handoff_id', 'template_sha256')) + + +def _pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError('Identity JSON repeats a field') + result[key] = value + return result + + +def object_json(raw): + """Decode one bounded JSON object with no duplicate keys or NUL bytes.""" + if not isinstance(raw, bytes) or len(raw) > MAX_ENCODED_BYTES or b'\0' in raw: + raise ValueError('Identity JSON exceeds its bound or contains a NUL') + try: + result = json.loads(raw.decode('utf-8'), object_pairs_hook=_pairs) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError('Invalid identity JSON') from error + if not isinstance(result, dict): + raise ValueError('Identity JSON must be an object') + return result + + +def _selected(result, fields): + if (not isinstance(result, dict) or set(result) != fields or + type(result.get('schema')) is not int or result['schema'] != 1 or + result.get('source') != 'host-usb' or not isinstance(result.get('profile'), str) or + not re.fullmatch(r'[a-z0-9][a-z0-9._-]*', result['profile'])): + raise ValueError('Unsupported recovery identity fields') + if (type(result.get('chip')) is not int or not 0 < result['chip'] <= 0xffffffff or + type(result.get('board')) is not int or not 0 <= result['board'] <= 0xffffffff): + raise ValueError('Recovery identity has invalid chip identity') + + +def validate_policy(result): + _selected(result, POLICY_FIELDS) + compatibles = result.get('compatibles') + if (not isinstance(compatibles, list) or not 1 <= len(compatibles) <= 32 or + any(not isinstance(value, str) or not re.fullmatch(r'apple,[a-zA-Z0-9._+-]{1,120}', value) + for value in compatibles) or len(set(compatibles)) != len(compatibles)): + raise ValueError('Recovery identity policy has invalid compatibles') + return result + + +def validate_handoff(result): + _selected(result, HANDOFF_FIELDS) + if type(result.get('ecid')) is not int or not 0 < result['ecid'] < 2**64: + raise ValueError('Host USB handoff has an invalid ECID') + handoff_id, template = result.get('handoff_id'), result.get('template_sha256') + if (not isinstance(handoff_id, str) or not re.fullmatch(r'[0-9a-f]{32}', handoff_id) or + int(handoff_id, 16) == 0 or not isinstance(template, str) or + not re.fullmatch(r'[0-9a-f]{64}', template)): + raise ValueError('Host USB handoff has invalid attempt provenance') + return result + + +def _validate(name, value): + if name == POLICY_PARAMETER: + return validate_policy(value) + if name == HANDOFF_PARAMETER: + return validate_handoff(value) + raise ValueError('Unknown recovery identity parameter') + + +def encode_parameter(name, value): + value = _validate(name, value) + raw = json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('ascii') + encoded = base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') + if len(encoded) > MAX_ENCODED_BYTES: + raise ValueError('Recovery identity token exceeds its bound') + return name + '=' + encoded + + +def _decode(name, value): + if not 1 <= len(value) <= MAX_ENCODED_BYTES or not re.fullmatch(r'[A-Za-z0-9_-]+', value): + raise ValueError('Invalid recovery handoff token') + try: + raw = base64.b64decode(value + '=' * (-len(value) % 4), altchars=b'-_', validate=True) + except (ValueError, binascii.Error) as error: + raise ValueError('Invalid recovery handoff encoding') from error + if base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') != value: + raise ValueError('Recovery handoff encoding is not canonical') + return _validate(name, object_json(raw)) + + +def parameters(cmdline): + """Return present identity parameters; callers decide which ones are required.""" + if isinstance(cmdline, bytes): + try: + cmdline = cmdline.decode('ascii') + except UnicodeError as error: + raise ValueError('Recovery command line is not ASCII') from error + if not isinstance(cmdline, str) or not cmdline.isascii(): + raise ValueError('Recovery command line is not ASCII') + # /proc/cmdline adds one newline to the kernel's bootargs string. + if cmdline.endswith('\n'): + cmdline = cmdline[:-1] + if len(cmdline) > MAX_CMDLINE_BYTES or any(value in cmdline for value in ('\0', '\r', '\n')): + raise ValueError('Recovery command line exceeds its bound or contains a control character') + result = {} + for token in cmdline.split(): + name, separator, value = token.partition('=') + if name not in (POLICY_PARAMETER, HANDOFF_PARAMETER): + if name.startswith('orchard.recovery-'): + raise ValueError('Unknown recovery identity parameter') + continue + if name in result or not separator: + raise ValueError('Repeated or incomplete recovery identity parameter') + result[name] = _decode(name, value) + return result diff --git a/src/orchard/recovery_policy.py b/src/orchard/recovery_policy.py new file mode 100644 index 00000000..065cdc81 --- /dev/null +++ b/src/orchard/recovery_policy.py @@ -0,0 +1,35 @@ +"""Descriptor-selected recovery identity and early runtime policy.""" +from pathlib import Path +import re +import tomllib + + +def settings(board, hardware=None): + from .hardware import ROOT, device + from .kernel import descriptor + from .recovery_identity import validate_policy + hardware = Path(hardware or ROOT) + selected = device(board, hardware) + platform = descriptor(hardware, board)['platform'] + table = tomllib.loads((hardware / 'platforms' / (platform + '.toml')).read_text()).get('recovery') + if table is None: + return None + expected = {'identity_source', 'usb_network', 'usb_modules', 'manual_modules', 'storage_activation'} + if (not isinstance(table, dict) or set(table) != expected or table['identity_source'] != 'host-usb' or + table['usb_network'] not in ('ecm', 'ncm') or table['storage_activation'] not in ('manual', 'automatic')): + raise ValueError('Invalid platform recovery policy') + modules = table['usb_modules'] + for key in ('usb_modules', 'manual_modules'): + names = table[key] + if (not isinstance(names, list) or any(not isinstance(name, str) or not re.fullmatch('[a-z0-9_-]+', name) for name in names) or + len(set(names)) != len(names)): + raise ValueError('Recovery modules must be distinct module names') + normalized = lambda names: {name.replace('-', '_') for name in names} + if normalized(table['usb_modules']) & normalized(table['manual_modules']): + raise ValueError('A manual module cannot be loaded during USB setup') + identity = validate_policy({'schema': 1, 'source': 'host-usb', 'profile': selected['device']['profile'], + 'compatibles': selected['device']['dt_compatible'], 'chip': selected['match']['dfu']['chip'], + 'board': selected['match']['dfu']['board']}) + runtime = {'schema': 1, 'profile': identity['profile'], 'usb_network': table['usb_network'], + 'usb_modules': modules, 'manual_modules': table['manual_modules'], 'storage_activation': table['storage_activation']} + return {'identity': identity, 'runtime': runtime} 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/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..eb4298eb --- /dev/null +++ b/tests/test_armv7_producers.py @@ -0,0 +1,354 @@ +"""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, inputs, distro +from orchard.hardware import ROOT +from orchard.image import elf, platform, prepare_runtime + + +def executable(architecture='arm', dynamic=False): + width = 1 if architecture == 'arm' else 2 + data = bytearray(384) + data[:7] = b'\x7fELF' + bytes((width, 1, 1)) + struct.pack_into('I', cells), + 'compatible': b'example,test\0'}, + 'children': [{'name': 'chosen', 'properties': {'bootargs': b'old\0', 'stdout-path': b'serial0\0'}, + '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('> "$TRACE"\n') + path.chmod(0o755) + # Model mount ownership, including descendants carried by --move. + # These commands only update a JSON fixture, never host mounts. + for name in ('mount', 'umount', 'switch_root'): + path = binary / name + path.write_text('#!' + sys.executable + '\n' + ''' +import json, os, pathlib, sys +name, args = pathlib.Path(sys.argv[0]).name, sys.argv[1:] +for source, target in (('/dev', os.environ['FIXTURE_DEV']), ('/run', os.environ['FIXTURE_RUN'])): + args = [target + arg[len(source):] if arg == source or arg.startswith(source + '/') else arg for arg in args] +state = pathlib.Path(os.environ['MOUNTS']) +mounts = json.loads(state.read_text()) if state.exists() else [] +with open(os.environ['TRACE'], 'a') as trace: + trace.write(name + ' ' + ' '.join(args) + '\\n') +if name == 'switch_root': + expected = [args[0] + '/' + part for part in ('dev', 'dev/pts', 'proc', 'sys', 'run')] + assert all(path in mounts for path in expected), (expected, mounts) +elif name == 'umount': + mounts.remove(args[-1]) +elif args[0] == '--move': + source, target = args[1:] + assert source in mounts, (source, mounts) + mounts = [target + path[len(source):] if path == source or path.startswith(source + '/') + else path for path in mounts] +else: + assert args[-1] not in mounts, ('duplicate mount', args[-1]) + mounts.append(args[-1]) +state.write_text(json.dumps(mounts)) +''') + path.chmod(0o755) + busybox = binary / 'busybox' + busybox.write_text('#!/bin/sh\n[ "$1" != sh ] || { shift; exec sh "$@"; }\n') + busybox.chmod(0o755) + for name in ('orchard-acm-rescue', 'dropbear', 'dropbearkey', + 'recovery-authorize-key', 'recovery-start-ssh'): + (binary / name).write_text('#!/bin/sh\necho serial-rescue >> "$TRACE"\n') + if lwvm: + (binary / 'orchard-lwvm').touch(mode=0o755) + (work / 'modules').write_text('test-storage\n') + (work / 'cmdline').write_text('' if install else 'root=PARTUUID=11111111-1111-4111-8111-111111111111\n') + hook = work / 'install/run' + if install: + hook.parent.mkdir() + hook.write_text('echo install-hook >> "$TRACE"\n' + f'mount -t tmpfs tmpfs {runtime}\n' + f'mount -t devpts devpts {work}/dev/pts\n' + 'root=11111111-1111-4111-8111-111111111111\n' + 'echo install-cleanup >> "$TRACE"\n' + f'umount {work}/dev/pts\n' + f'umount {runtime}\n') + text = INIT.read_text() + setup = text.split('# gpt_partuuid ')[0] + setup = re.sub(r'^usb_gadget\(\) \{.*?^\}', + 'usb_gadget() { echo installed-gadget >> "$TRACE"; }', setup, flags=re.M | re.S) + setup = re.sub(r'^exec &2; exit 1; }', + setup, flags=re.M | re.S) + handoff = text[text.index('moved=\n'):] + # Rescue fixture children exit immediately; no real process is signalled. + handoff = re.sub(r'^\[ -z .*?\|\| true$', ':', handoff, flags=re.M) + script = setup + '\nwait\n' + handoff + script = script.replace('export PATH=/bin', 'export PATH="$FIXTURE_BIN:$PATH"') + paths = dict(( + ('/etc/orchard/modules', str(work / 'modules')), + ('/proc/cmdline', str(work / 'cmdline')), + ('/install/run', str(hook)), ('/bin/', str(binary) + '/'), + ('/newroot', str(work / 'newroot')), ('/run', str(runtime)), + ('/dev', str(work / 'dev')))) + script = re.sub('|'.join(re.escape(path) for path in paths), + lambda match: paths[match[0]], script) + result = subprocess.run(['sh', '-c', script], stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=5, + env={**os.environ, 'TRACE': str(trace), 'FIXTURE_BIN': str(binary), + 'MOUNTS': str(state), 'FIXTURE_DEV': str(work / 'dev'), + 'FIXTURE_RUN': str(runtime)}) + self.assertEqual(result.returncode, 0, result.stderr) + return trace.read_text().splitlines() + + def test_install_hook_sets_root_after_modules_and_owns_ram_and_usb(self): + for lwvm in (False, True): + with self.subTest(lwvm=lwvm): + events = self.run_init(install=True, lwvm=lwvm) + self.assertLess(events.index('orchard-load-storage test-storage'), events.index('install-hook')) + self.assertEqual(sum(line.startswith('mount -t tmpfs') for line in events), 2) + self.assertEqual(sum(line.startswith('mount -t devpts') for line in events), 2) + cleanup = events.index('install-cleanup') + self.assertTrue(any(line.startswith('mount -t tmpfs') for line in events[cleanup + 1:])) + self.assertTrue(any(line.startswith('mount -t devpts') for line in events[cleanup + 1:])) + self.assertNotIn('installed-gadget', events) + self.assertNotIn('serial-rescue', events) + self.assertEqual(sum('mount --move' in line and '/run ' in line for line in events), 1) + self.assertTrue(events[-1].startswith('switch_root ')) + + def test_ordinary_lwvm_root_keeps_early_rescue_and_moves_its_ram_state(self): + events = self.run_init(install=False, lwvm=True) + self.assertEqual(events.count('installed-gadget'), 1) + self.assertEqual(events.count('serial-rescue'), 1) + self.assertLess(events.index('installed-gadget'), events.index('orchard-load-storage test-storage')) + self.assertEqual(sum('mount --move' in line and '/run ' in line for line in events), 1) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_root_storage_policy.py b/tests/test_root_storage_policy.py new file mode 100644 index 00000000..b7ce5548 --- /dev/null +++ b/tests/test_root_storage_policy.py @@ -0,0 +1,73 @@ +"""Validate the initrd's explicit storage policy without loading a real module.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from orchard.hardware import ROOT + + +class StoragePolicy(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.work = Path(temporary.name) + self.capture = self.work / 'arguments' + probe = self.work / 'modprobe' + probe.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$CAPTURE"\n') + probe.chmod(0o755) + self.cmdline = self.work / 'cmdline' + + def invoke(self, commandline, module='apple-sftl'): + self.capture.unlink(missing_ok=True) + self.cmdline.write_text(commandline + '\n') + shell = [os.environ['ORCHARD_TEST_BUSYBOX'], 'sh'] if os.environ.get('ORCHARD_TEST_BUSYBOX') else ['sh'] + return subprocess.run([*shell, str(ROOT / 'boot/initramfs/root/load-storage'), module, str(self.cmdline)], + env={**os.environ, 'PATH': str(self.work) + ':' + os.environ['PATH'], + 'CAPTURE': str(self.capture)}, capture_output=True, text=True) + + def test_rw_mount_option_keeps_the_default_backend_gate(self): + result = self.invoke('root=PARTUUID=1234 rw quiet') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.capture.read_text().splitlines(), ['apple-sftl']) + + def test_explicit_uint32_policy_reaches_modprobe_as_separate_arguments(self): + result = self.invoke('apple_sftl.write_enable=1 apple_sftl.max_page_reads=4294967295 ' + 'apple_sftl.max_io_time_ms=4294967295') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.capture.read_text().splitlines(), [ + 'apple-sftl', 'write_enable=1', 'max_page_reads=4294967295', 'max_io_time_ms=4294967295']) + + def test_write_opt_in_requires_an_explicit_read_and_lifetime_budget(self): + for tail in ('', ' apple_sftl.max_page_reads=1', ' apple_sftl.max_io_time_ms=1'): + result = self.invoke('apple_sftl.write_enable=1' + tail) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.capture.exists()) + + def test_invalid_or_repeated_policy_never_loads_the_module(self): + for argument in ('write_enable=true', 'write_enable=2', 'max_page_reads=0', + 'max_io_time_ms=-1', 'max_page_reads=01', 'max_page_reads=4294967296', + 'max_page_reads=9999999999999999999999', 'max_page_reads=1x', + 'max_page_reads=*', 'unknown=1', 'max_page_reads', 'max_io_time_ms='): + result = self.invoke('apple_sftl.' + argument) + self.assertNotEqual(result.returncode, 0, argument) + self.assertFalse(self.capture.exists(), argument) + for argument in ('write_enable=0', 'max_page_reads=1', 'max_io_time_ms=1'): + result = self.invoke(f'apple_sftl.{argument} apple-sftl.{argument}') + self.assertNotEqual(result.returncode, 0, argument) + self.assertFalse(self.capture.exists(), argument) + + def test_explicit_read_only_policy_and_other_storage_modules(self): + result = self.invoke('apple-sftl.write_enable=0 apple-sftl.max_page_reads=100 apple-sftl.max_io_time_ms=200') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.capture.read_text().splitlines(), [ + 'apple-sftl', 'write_enable=0', 'max_page_reads=100', 'max_io_time_ms=200']) + result = self.invoke('apple_sftl.write_enable=1', module='nvme') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.capture.read_text().splitlines(), ['nvme']) + + def test_module_name_cannot_be_a_modprobe_option(self): + result = self.invoke('', module='--remove') + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.capture.exists()) diff --git a/tools/kernel-tree/README.md b/tools/kernel-tree/README.md index b7c4fb18..2a35a194 100644 --- a/tools/kernel-tree/README.md +++ b/tools/kernel-tree/README.md @@ -23,6 +23,27 @@ files reproduce the branch. The topic comes from the files a commit touches; if they point to several topics it asks for `--topic SHA=TOPIC`. Changes to an Orchard defconfig belong in `kernel/configs//config` instead. +When a topic applies to one platform's kernel base, pass `--platform` after +testing the branch on hardware: + +```sh +~/orchard/tools/kernel-tree/export.py --orchard ~/orchard --linux . \ + --base armv7 --platform apple-armv7 my-work +``` + +Each exported patch receives an adjacent `.patch.toml` with schema 1 and the +selected platform names. Export adds `scoped patches/` to the selected +series when no existing layer covers that directory. A scoped layer selects +only explicitly matching patches, which lets ARMv7 share subsystem directories +with ARM64. Ordinary layers keep including unscoped patches and skip patches +scoped to another platform. Existing patch files keep their names and bytes. +`--dry-run` prints the plan without writing files or reserving numbers. + +Repeat `--platform` for every platform on the same generated branch, such as +`--platform apple-4k --platform apple-16k`. Regeneration requires those +platforms to select the same ordered patches. Without `--platform`, export +keeps writing unscoped patches and leaves the series files alone. + On macOS the kernel tree needs a case-sensitive volume, and the kernel builds in a Linux container. @@ -39,7 +60,8 @@ per patch and one merge per patch directory, adds the platforms' defconfigs (checked to reproduce `kernel/configs`), and moves the branch; m1n1 `main` likewise. It pushes nothing. -Regeneration is stable: a commit keeps the committer and date of the orchard -commit that last changed its patch file, and a merge cites the orchard commit -that last changed its directory, so topics before the first changed one keep -their SHAs. Branches based on an older `main` rebase cleanly onto a new one. +Regeneration uses the committed platform selector, including patch scopes. +A commit keeps the committer and date of the orchard commit that last changed +its patch file or adjacent scope, and a merge cites the latest change to its +selected patch inputs. Topics before the first changed one keep their SHAs. +Branches based on an older `main` rebase cleanly onto a new one. diff --git a/tools/kernel-tree/convert.py b/tools/kernel-tree/convert.py index eb821358..261eb6a7 100755 --- a/tools/kernel-tree/convert.py +++ b/tools/kernel-tree/convert.py @@ -22,6 +22,7 @@ import argparse import atexit import os +import runpy import shutil import subprocess import sys @@ -36,6 +37,7 @@ parser.add_argument('--target', required=True, type=Path, help='linux or m1n1 repository') parser.add_argument('--base', required=True, help='upstream base in the target repository') parser.add_argument('--series', help='series file relative to --layers, e.g. series/apple-16k') +parser.add_argument('--platform', help='kernel platform for patch selection (default: series file name)') parser.add_argument('--refs', help='topic refs go to refs/topics//') parser.add_argument('--map', type=Path, help='write "patch pathcommit" lines here') args = parser.parse_args() @@ -49,12 +51,13 @@ def run(*cmd, env=None, input=None, cwd=None): commit = run('git', '-C', args.orchard, 'rev-parse', '--short', args.commit) -work = Path(tempfile.mkdtemp(prefix='kernel-tree-')) +work = Path(tempfile.mkdtemp(prefix='kernel-tree-')).resolve() atexit.register(shutil.rmtree, work, ignore_errors=True) index = work / 'index' source = work / 'src' source.mkdir() -archive = subprocess.run(['git', '-C', args.orchard, 'archive', commit, args.layers], +archive_paths = [args.layers, 'platforms'] if args.layers == 'kernel' else [args.layers] +archive = subprocess.run(['git', '-C', args.orchard, 'archive', commit, *archive_paths], capture_output=True, check=True).stdout run('tar', '-x', '-C', source, input=archive) layers = source / args.layers @@ -69,30 +72,45 @@ def orchard_log(fmt, *rest): return run('git', '-C', args.orchard, 'log', f'--format={fmt}', commit, *rest) -def last_change(path): - """Committer of the orchard commit that last changed path.""" - name, email, date = orchard_log('%cn\t%ce\t%cD', '-1', '--', path).split('\t') +def last_change(*paths): + """Committer of the latest change to the patch or its selection metadata.""" + name, email, date = orchard_log('%cn\t%ce\t%cD', '-1', '--', *paths).split('\t') return {'GIT_COMMITTER_NAME': name, 'GIT_COMMITTER_EMAIL': email, 'GIT_COMMITTER_DATE': date} -# Patch order exactly as the build applies it: series layers in order; the -# kernel's series.py takes every patch below a layer (rglob), m1n1's build -# only the layer's own files. -if args.series: - series_file, find = layers / args.series, 'rglob' -elif (layers / 'series').is_file(): - series_file, find = layers / 'series', 'glob' +# Kernel builds and regeneration use the selector from the same snapshot. +# m1n1 retains its existing series contract. +if args.layers == 'kernel': + if not args.series: + sys.exit('kernel has one series per platform; name one with --series') + series_api = runpy.run_path(str(layers / 'series.py')) + selected_platform = args.platform or Path(args.series).name + described = series_api['platform'](selected_platform) + if (source / described['series']).resolve() != (layers / args.series).resolve(): + sys.exit(f'{args.series} is not {selected_platform}\'s patch series') + patches = series_api['patches'](selected_platform) else: - sys.exit(f'{args.layers} has one series per platform; name one with --series') -if not series_file.is_file(): - sys.exit(f'No series {args.series} in {args.layers} at {commit}') -series = series_file.read_text() + if args.platform: + sys.exit('--platform applies only to kernel patch layers') + series_file = layers / (args.series or 'series') + if not series_file.is_file(): + sys.exit(f'No series {args.series} in {args.layers} at {commit}') + patches = [] + find = 'rglob' if args.series else 'glob' + for line in series_file.read_text().splitlines(): + line = line.split('#', 1)[0].strip() + if line: + patches += sorted(getattr(layers / line, find)('*.patch')) refs = args.refs or Path(args.layers).name -patches = [] -for line in series.splitlines(): - line = line.split('#', 1)[0].strip() - if line: - patches += sorted(getattr(layers / line, find)('*.patch')) + + +def patch_inputs(path): + inputs = [str(path.relative_to(source))] + # Include the potential path even when absent so removing a scope keeps + # the deletion's provenance. Git ignores paths with no history. + if args.layers == 'kernel': + inputs.append(str(path.relative_to(source)) + '.toml') + return inputs topics = [] for p in patches: @@ -134,7 +152,7 @@ def source_note(topic): message = run('git', 'stripspace', input=(fields['Subject'] + ('\n\n' + body if body.strip() else '\n')).encode()) git('apply', '--cached', str(p)) - committer = last_change(rel) + committer = last_change(*patch_inputs(p)) tip = git('commit-tree', git('write-tree'), '-p', tip, input=(message + '\n').encode(), env={ 'GIT_AUTHOR_NAME': fields['Author'], 'GIT_AUTHOR_EMAIL': fields['Email'], 'GIT_AUTHOR_DATE': fields['Date'], **committer}) @@ -142,7 +160,7 @@ def source_note(topic): git('update-ref', f'refs/topics/{refs}/{topic}', tip) # The merge is authored and committed as the topic's most recent change. newest = orchard_log('%h\t%an\t%ae\t%cn\t%ce\t%cD', '-1', '--', - *(str(p.relative_to(source)) for p in files)) + *(path for p in files for path in patch_inputs(p))) changed, an, ae, cn, ce, date = newest.split('\t') count = len(files) message = (f'Merge {topic} ({count} patch{"es" if count != 1 else ""})\n\n' diff --git a/tools/kernel-tree/export.py b/tools/kernel-tree/export.py index 9017e684..371721e0 100755 --- a/tools/kernel-tree/export.py +++ b/tools/kernel-tree/export.py @@ -18,12 +18,13 @@ a commit is exported as usual. usage: export.py --orchard DIR --linux DIR [--base REV] [--topic SHA=TOPIC ...] - [--dry-run] BRANCH + [--platform NAME ...] [--dry-run] BRANCH """ import argparse import json import os import re +import runpy import subprocess import sys import tempfile @@ -35,11 +36,15 @@ parser.add_argument('--base', default='main', help='where BRANCH started (default: main)') parser.add_argument('--topic', action='append', default=[], metavar='SHA=TOPIC', help='topic for one commit, e.g. 1a2b3c=drivers/input') +parser.add_argument('--platform', action='append', default=[], metavar='NAME', + help='scope exported patches to this platform; repeat for shared branches') parser.add_argument('--dry-run', action='store_true', help='show the plan, write nothing') parser.add_argument('branch') args = parser.parse_args() args.orchard, args.linux = args.orchard.resolve(), args.linux.resolve() patches = args.orchard / "kernel/patches" +series_api = runpy.run_path(str(args.orchard / 'kernel/series.py')) +selected_platforms = sorted(set(args.platform)) def git(*cmd, input=None): @@ -61,7 +66,7 @@ def git(*cmd, input=None): def topic_of(path): - m = re.match(r'arch/arm(64)?/boot/dts/apple/([a-z]+[0-9]+[a-z]?)[-.]', path) + m = re.match(r'arch/arm(64)?/boot/dts/apple/([a-z][a-z0-9]*)[-.]', path) if m: return f'devices/{m[2]}' for prefix, topic in sorted(DRIVERS.items(), key=lambda kv: -len(kv[0])): @@ -89,12 +94,34 @@ def topic_of(path): DEFCONFIG = re.compile(r'arch/(arm|arm64)/configs/orchard_[a-z0-9_]+_defconfig$') # defconfig path -> (platform, arch, defconfig name, orchard config path), from orchard's descriptors. -platforms = {} +platforms, descriptions = {}, {} for descriptor in sorted((args.orchard / 'platforms').glob('*.toml')): - described = json.loads(subprocess.run([sys.executable, str(args.orchard / 'kernel/series.py'), '--platform', - descriptor.stem], capture_output=True, check=True, text=True).stdout) + described = series_api['platform'](descriptor.stem) + descriptions[descriptor.stem] = described platforms[f'arch/{described["arch"]}/configs/{described["defconfig"]}'] = ( descriptor.stem, described['arch'], described['defconfig'], described['config']) +for name in selected_platforms: + if name not in descriptions: + sys.exit(f'Unknown platform {name}; choose from {", ".join(descriptions)}') + shared = {p for p, value in descriptions.items() + if value['branch'] == descriptions[name]['branch']} + missing = shared - set(selected_platforms) + if missing: + sys.exit(f'{name} shares kernel branch {descriptions[name]["branch"]} with ' + f'{", ".join(sorted(missing))}; include those with --platform too') +# Refuse malformed existing selection metadata before creating any files. +for name in descriptions: + series_api['patches'](name) + + +def topic_directory(topic): + if (not isinstance(topic, str) or not topic or Path(topic).is_absolute() or + any(not re.fullmatch(r'[a-z0-9][a-z0-9_-]*', part) for part in topic.split('/'))): + sys.exit(f'Invalid patch topic: {topic}') + directory = (patches / topic).resolve() + if patches.resolve() not in directory.parents: + sys.exit(f'Patch topic leaves kernel/patches: {topic}') + return directory plan, problems, configs = [], [], set() for sha in commits: @@ -104,6 +131,8 @@ def topic_of(path): for path in changed: if path not in platforms: problems.append(f'{sha[:12]} {subject}: {path} is no platform\'s defconfig') + if selected_platforms and path in platforms and platforms[path][0] not in selected_platforms: + problems.append(f'{sha[:12]} {subject}: {path} is outside the selected platforms') configs.add(path) files = [f for f in files if f not in changed] if not files: @@ -119,6 +148,7 @@ def topic_of(path): f'{", ".join(sorted(candidates)) or "no topic"}; name one with ' f'--topic {sha[:12]}=') continue + topic_directory(topic) plan.append((sha, subject, topic)) if problems: sys.exit('Not exported:\n ' + '\n '.join(problems)) @@ -126,7 +156,7 @@ def topic_of(path): numbers = {} writes = [] for sha, subject, topic in plan: - directory = patches / topic + directory = topic_directory(topic) if topic not in numbers: existing = [int(p.name[:4]) for p in directory.glob('[0-9][0-9][0-9][0-9]-*.patch')] numbers[topic] = max(existing, default=0) @@ -136,16 +166,70 @@ def topic_of(path): print(f'{topic}/{numbers[topic]:04d} {subject}{new}') for path in sorted(configs): print(f'{platforms[path][3]} from {path} on {args.branch}') +layer_additions = {} +for name in selected_platforms: + layers = series_api['series_layers'](name) + missing = [] + for _, _, topic, _ in writes: + directory = topic_directory(topic) + if any(layer == directory or layer in directory.parents for layer, _ in layers): + continue + relative = directory.relative_to(args.orchard / 'kernel').as_posix() + entry = f'scoped {relative}' + if entry not in missing: + missing.append(entry) + layer_additions[name] = missing + planned_layers = layers + [(args.orchard / 'kernel' / entry.split()[1], True) + for entry in missing] + # A new parent layer can overlap a pre-existing child layer. Check both + # existing selected patches and future exports before creating any files. + seen = set() + for layer, scoped in planned_layers: + for patch, scope in series_api['layer_patches'](layer): + if (scope is None and scoped) or (scope is not None and name not in scope): + continue + if patch.resolve() in seen: + sys.exit(f'Planned layers for {name} select {patch} more than once') + seen.add(patch.resolve()) + for _, _, topic, _ in writes: + directory = topic_directory(topic) + count = sum(layer == directory or layer in directory.parents + for layer, _ in planned_layers) + if count != 1: + sys.exit(f'Planned layers for {name} overlap at {topic}; use non-overlapping topics') + print(f'Patch scope: {name}') + for entry in missing: + print(f'{descriptions[name]["series"]} add {entry}') + if args.dry_run: sys.exit(0) written = [] for sha, subject, topic, number in writes: - directory = patches / topic + directory = topic_directory(topic) directory.mkdir(parents=True, exist_ok=True) name = git('format-patch', '--zero-commit', '--no-signature', '--start-number', str(number), '-o', str(directory), '-1', sha, '--', '.', ':(exclude,glob)arch/*/configs/orchard_*_defconfig') - written.append(Path(name)) + path = Path(name) + written.append(path) + if selected_platforms: + Path(str(path) + '.toml').write_text('schema = 1\nplatforms = [' + + ', '.join(json.dumps(name) for name in selected_platforms) + ']\n') + +series_updates = {} +for name, additions in layer_additions.items(): + series = (args.orchard / descriptions[name]['series']).resolve() + entries = series_updates.setdefault(series, []) + entries.extend(entry for entry in additions if entry not in entries) +for series, additions in series_updates.items(): + if additions: + text = series.read_text() + series.write_text(text + ('' if not text or text.endswith('\n') else '\n') + + '\n'.join(additions) + '\n') +for name in selected_platforms: + selected = set(series_api['patches'](name)) + if not set(written).issubset(selected): + sys.exit(f'Exported patches are missing from {name}') # Defconfig changes: the branch's defconfig, expanded, becomes orchard's config. IMAGE = 'localhost/orchard-hardware-kernel-toolchain:bookworm' diff --git a/tools/kernel-tree/regenerate b/tools/kernel-tree/regenerate index 7076b911..112a4f04 100755 --- a/tools/kernel-tree/regenerate +++ b/tools/kernel-tree/regenerate @@ -74,18 +74,22 @@ try: def lock(path): return dict(l.split('=', 1) for l in (snapshot / path).read_text().splitlines() if '=' in l) - def layers(path): - """The series' layer lines, without comments: what is applied.""" - return [l.split('#', 1)[0].strip() for l in (snapshot / path).read_text().splitlines() - if l.split('#', 1)[0].strip()] + def selected(name): + """What the snapshot's kernel build applies, including patch scopes.""" + return run(sys.executable, snapshot / 'kernel/series.py', name).splitlines() + selections = {name: selected(name) for name in platforms} + # Validate every branch before fetching, creating refs or running a toolchain. for branch, names in sorted(branches.items()): first = described[names[0]] for name in names[1:]: if lock(described[name]['upstream']) != lock(first['upstream']): sys.exit(f'{name} and {names[0]} share branch {branch} but not their upstream base') - if layers(described[name]['series']) != layers(first['series']): - sys.exit(f'{name} and {names[0]} share branch {branch} but not their patch layers') + if selections[name] != selections[names[0]]: + sys.exit(f'{name} and {names[0]} share branch {branch} but select different patches') + + for branch, names in sorted(branches.items()): + first = described[names[0]] git(args.linux, 'check-ref-format', '--branch', branch) base = lock(first['upstream']) if subprocess.run(['git', '-C', args.linux, 'cat-file', '-e', base['commit'] + '^{commit}'], @@ -106,7 +110,7 @@ try: subprocess.run([sys.executable, '-I', HERE / 'convert.py', '--orchard', ORCHARD, '--commit', commit, '--layers', 'kernel', '--series', Path(first['series']).relative_to('kernel'), - '--refs', branch, '--target', args.linux, '--base', base['commit'], + '--platform', names[0], '--refs', branch, '--target', args.linux, '--base', base['commit'], '--map', args.linux / '.git' / f'orchard-patch-map-{branch.replace("/", "-")}'], check=True) converted = git(args.linux, 'rev-parse', 'refs/orchard/converted')