From 104b73c15217834e3fb9b863e80951d4bbeaf03e Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:01:09 -0600 Subject: [PATCH 01/14] build: add ARMv7 userspace and release producers Select an armv7h userspace for Orchard arm platforms and carry the target through desktop components, root assembly and release auditing. Check package metadata and native ELF headers before packaging, and isolate component caches by architecture and compiler identity. Build ARMv7 Omacalc from its pinned upstream source and use the selected Cascadia board profile for recovery assembly. The ARMv7 producer contract is supplied by the companion Orchard change. Assisted-by: LLM --- desktop/armv7/sources.json | 10 + desktop/build | 88 +++++--- desktop/build-cursor-helper | 15 +- desktop/build-labwc | 17 +- desktop/build-sources | 54 +++-- desktop/install-toolchain | 2 +- desktop/labwc/install-toolchain | 2 +- desktop/packages/omacalc/PKGBUILD | 36 ++++ desktop/packages/omafox/PKGBUILD | 2 +- desktop/packages/wlroots0.20/PKGBUILD | 2 +- desktop/rank-mirrors | 1 + desktop/stage.py | 6 + desktop/target.py | 75 +++++++ orchard.toml | 1 + release/architecture.py | 68 ++++++ release/audit.py | 187 +++++++++++++++- release/build.py | 191 ++++++++++++++--- release/manifests.py | 56 +++-- release/public_root.py | 53 +++-- release/recovery_audit.py | 89 ++++++++ system/assemble-rootfs | 25 ++- system/build-rootfs | 38 ++-- system/install-toolchain | 2 +- system/rank-mirrors | 1 + tests/test_architecture.py | 144 +++++++++++++ tests/test_desktop_architecture.py | 166 +++++++++++++++ tests/test_extraction.py | 14 +- tests/test_greeter_staging.py | 7 +- tests/test_release_architecture.py | 293 ++++++++++++++++++++++++++ tools/build.py | 115 +++++++--- 30 files changed, 1587 insertions(+), 173 deletions(-) create mode 100644 desktop/armv7/sources.json create mode 100644 desktop/packages/omacalc/PKGBUILD create mode 100644 desktop/target.py create mode 100644 release/architecture.py create mode 100644 release/recovery_audit.py create mode 100644 tests/test_architecture.py create mode 100644 tests/test_desktop_architecture.py create mode 100644 tests/test_release_architecture.py diff --git a/desktop/armv7/sources.json b/desktop/armv7/sources.json new file mode 100644 index 0000000..a880a88 --- /dev/null +++ b/desktop/armv7/sources.json @@ -0,0 +1,10 @@ +{ + "omacalc": { + "version": "0.3.1", + "commit": "573d7106f5c6c0225864eb8634ae37ddcb657f63", + "urls": [ + "https://github.com/omacom/omacalc/archive/573d7106f5c6c0225864eb8634ae37ddcb657f63.tar.gz" + ], + "sha256": "6a4ad731acb4f68226b975e1b817b2907fea1534d52d1e769564a76a48f3eafc" + } +} diff --git a/desktop/build b/desktop/build index 21339a9..07b3e42 100755 --- a/desktop/build +++ b/desktop/build @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build common AArch64 desktop components and stage the pinned Omarchy UI.""" +"""Build target desktop components and stage the pinned Omarchy UI.""" import argparse import hashlib import json @@ -10,6 +10,7 @@ import subprocess import sys import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, source_pins, check_package native = buildenv.native HERE = Path(__file__).resolve().parent @@ -39,9 +40,9 @@ def recipe_sha(directory): return digest.hexdigest() -def source_metadata(built): - pins = json.loads((HERE / 'sources.json').read_text()) - if built['architecture'] != 'aarch64' or built['source_sha256'] != { +def source_metadata(built, architecture='aarch64'): + pins = source_pins(architecture) + if built['architecture'] != architecture or built['source_sha256'] != { name: entry['sha256'] for name, entry in pins.items()}: raise ValueError('Source-build pins differ') if any(built['source_urls'][name] not in entry['urls'] for name, entry in pins.items()): @@ -50,26 +51,36 @@ def source_metadata(built): built['pkgbuild_sha256'] != sha(HERE / 'packages/omafox/PKGBUILD') or built.get('wlroots_recipe_sha256') != recipe_sha(HERE / 'packages/wlroots0.20')): raise ValueError('Source-build recipe differs') - if set(built['files']) != {'wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz'}: + expected = {'wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz'} + if architecture == 'armv7h': + expected.add('omacalc.pkg.tar.zst') + if built.get('omacalc_recipe_sha256') != recipe_sha(HERE / 'packages/omacalc'): + raise ValueError('Omacalc source-build recipe differs') + if set(built['files']) != expected: raise ValueError('Source-build files differ') return built['files'] -def verify(directory): - pins = json.loads(PINS.read_text()) +def verify(directory, architecture=None): meta = json.loads((directory / 'manifest.json').read_text()) + architecture = validate(architecture or meta['architecture']) + pins = json.loads(PINS.read_text()) + files = FILES if architecture == 'aarch64' else {'omafox_xpi': FILES['omafox_xpi']} lock = dict(line.split('=', 1) for line in (HERE / 'upstream.lock').read_text().splitlines() if '=' in line) - if (meta['architecture'] != pins['architecture'] or + if (meta['architecture'] != architecture or meta['omarchy_commit'] != lock['omarchy_commit'] or - meta['inputs'] != {name: pins[name] for name in FILES}): + meta['inputs'] != {name: pins[name] for name in files}): raise ValueError('Desktop input manifest differs') - built_files = source_metadata(meta['source_build']) + built_files = source_metadata(meta['source_build'], architecture) packages = directory / 'packages' if sha(packages / 'omafox.pkg.tar.xz') != built_files['omafox.pkg.tar.xz']: raise ValueError('Source-built Omafox package differs') if sha(packages / 'wlroots0.20.pkg.tar.xz') != built_files['wlroots0.20.pkg.tar.xz']: raise ValueError('Source-built wlroots package differs') - if sha(packages / 'omacalc.pkg.tar.zst') != pins['omacalc_package']: + omacalc_sha = pins['omacalc_package'] if architecture == 'aarch64' else built_files['omacalc.pkg.tar.zst'] + for name in ('omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz', 'omacalc.pkg.tar.zst'): + check_package(packages / name, architecture) + if sha(packages / 'omacalc.pkg.tar.zst') != omacalc_sha: raise ValueError('Signed Omacalc package differs') payload = directory / 'payload' manifest = payload / 'arch-ipad-ui-manifest.json' @@ -84,6 +95,7 @@ def verify(directory): if p.is_file() and not p.is_symlink()} if actual != set(contents) | {'arch-ipad-ui-manifest.json'}: raise ValueError('Desktop UI files differ from manifest') + check_elf(payload / KEYBOARD_PATH, architecture, 'keyboard') if sha(payload / KEYBOARD_PATH) != built_files['wvkbd-mobintl']: raise ValueError('Source-built keyboard differs') if sha(payload / XPI_PATH) != pins['omafox_xpi']: @@ -91,18 +103,25 @@ def verify(directory): return meta -def build(output): +def build(output, architecture='aarch64', builder_image=None): + validate(architecture) if output.exists(): raise ValueError('Output already exists') pins = json.loads(PINS.read_text()) - for name, path in FILES.items(): + files = FILES if architecture == 'aarch64' else {'omafox_xpi': FILES['omafox_xpi']} + for name, path in files.items(): if sha(path) != pins[name]: raise ValueError(f'Pinned desktop input differs: {name}') lock = dict(line.split('=', 1) for line in (HERE / 'upstream.lock').read_text().splitlines() if '=' in line) commit = lock['omarchy_commit'] if len(commit) != 40 or any(c not in '0123456789abcdef' for c in commit): raise ValueError('Invalid Omarchy commit pin') - for tool in (('git', 'gpg') if native() else ('git', 'gpg', 'podman')): + required = ['git', 'bsdtar'] + if architecture == 'aarch64': + required.append('gpg') + if not native(): + required.append('podman') + for tool in required: if shutil.which(tool) is None: raise RuntimeError(f'{tool} is required') (HERE / '.work').mkdir(exist_ok=True) @@ -111,18 +130,22 @@ def build(output): with tempfile.TemporaryDirectory(prefix='desktop-', dir=HERE / '.work') as temporary, \ tempfile.TemporaryDirectory(prefix='oa-gpg-') as gnupg: work = Path(temporary) - subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--import', - str(INPUTS / 'omarchy-pkgs.asc')], check=True, stdout=subprocess.DEVNULL) - result = subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--status-fd', '1', - '--verify', str(INPUTS / 'omacalc.pkg.tar.zst.sig'), - str(FILES['omacalc_package'])], check=True, capture_output=True, text=True) - if not any(line.startswith('[GNUPG:] VALIDSIG ' + pins['omacalc_signer'] + ' ') - for line in result.stdout.splitlines()): - raise ValueError('Omacalc signature has the wrong signer') + if architecture == 'aarch64': + subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--import', + str(INPUTS / 'omarchy-pkgs.asc')], check=True, stdout=subprocess.DEVNULL) + result = subprocess.run(['gpg', '--homedir', str(gnupg), '--batch', '--status-fd', '1', + '--verify', str(INPUTS / 'omacalc.pkg.tar.zst.sig'), + str(FILES['omacalc_package'])], check=True, capture_output=True, text=True) + if not any(line.startswith('[GNUPG:] VALIDSIG ' + pins['omacalc_signer'] + ' ') + for line in result.stdout.splitlines()): + raise ValueError('Omacalc signature has the wrong signer') sources = work / 'source-build' - subprocess.run([sys.executable, str(HERE / 'build-sources'), '--output', str(sources)], check=True) + options = ['--architecture', architecture] + if builder_image: + options.extend(['--builder-image', builder_image]) + subprocess.run([sys.executable, str(HERE / 'build-sources'), '--output', str(sources), *options], check=True) built = json.loads((sources / 'manifest.json').read_text()) - built_files = source_metadata(built) + built_files = source_metadata(built, architecture) for name, digest in built_files.items(): if sha(sources / name) != digest: raise ValueError(f'Source-built file differs: {name}') @@ -141,17 +164,18 @@ def build(output): subprocess.run([sys.executable, str(HERE / 'stage.py'), str(result_dir / 'payload'), '--upstream', str(upstream), '--keyboard', str(sources / 'wvkbd-mobintl'), '--keyboard-sha256', built_files['wvkbd-mobintl'], - '--omafox-xpi', str(FILES['omafox_xpi'])], check=True) + '--omafox-xpi', str(FILES['omafox_xpi']), '--architecture', architecture], check=True) packages = result_dir / 'packages' packages.mkdir() shutil.copyfile(sources / 'omafox.pkg.tar.xz', packages / 'omafox.pkg.tar.xz') shutil.copyfile(sources / 'wlroots0.20.pkg.tar.xz', packages / 'wlroots0.20.pkg.tar.xz') - shutil.copyfile(FILES['omacalc_package'], packages / 'omacalc.pkg.tar.zst') - meta = {'architecture': 'aarch64', 'omarchy_commit': commit, - 'inputs': {name: pins[name] for name in FILES}, 'source_build': built, + calculator = FILES['omacalc_package'] if architecture == 'aarch64' else sources / 'omacalc.pkg.tar.zst' + shutil.copyfile(calculator, packages / 'omacalc.pkg.tar.zst') + meta = {'architecture': architecture, 'omarchy_commit': commit, + 'inputs': {name: pins[name] for name in files}, 'source_build': built, 'payload_manifest_sha256': sha(result_dir / 'payload/arch-ipad-ui-manifest.json')} (result_dir / 'manifest.json').write_text(json.dumps(meta, indent=2) + '\n') - verify(result_dir) + verify(result_dir, architecture) output.parent.mkdir(parents=True, exist_ok=True) os.replace(result_dir, output) print('Desktop output:', output) @@ -162,12 +186,14 @@ def main(): action = parser.add_mutually_exclusive_group(required=True) action.add_argument('--output', type=Path) action.add_argument('--verify', type=Path) + parser.add_argument('--architecture', choices=('aarch64', 'armv7h')) + parser.add_argument('--builder-image', help='existing target compiler image') args = parser.parse_args() try: if args.output: - build(args.output.resolve()) + build(args.output.resolve(), args.architecture or 'aarch64', args.builder_image) else: - verify(args.verify) + verify(args.verify, args.architecture) print('Verified desktop output:', args.verify) except (OSError, KeyError, ValueError, subprocess.CalledProcessError) as exc: parser.error(str(exc)) diff --git a/desktop/build-cursor-helper b/desktop/build-cursor-helper index 73856db..262dc13 100755 --- a/desktop/build-cursor-helper +++ b/desktop/build-cursor-helper @@ -9,6 +9,7 @@ import shutil import subprocess import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, machine, podman_arch, builder, source_pins, check_package from urllib.request import urlopen import xml.etree.ElementTree as ET @@ -23,11 +24,13 @@ def sha(path): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True, type=Path) - parser.add_argument('--image', default=IMAGE, help='existing desktop compiler image') + parser.add_argument('--architecture', default='aarch64', choices=('aarch64', 'armv7h')) + parser.add_argument('--builder-image', '--image', dest='builder_image', help='existing target compiler image') args = parser.parse_args() output = args.output.resolve() if output.exists(): parser.error('Choose a new output directory') + image = builder(buildenv, args.architecture, args.builder_image) pin = json.loads((HERE / 'cursor/wtype.json').read_text()) cache = HERE / '.work/cursor' cache.mkdir(parents=True, exist_ok=True) @@ -49,12 +52,14 @@ wayland-scanner private-code protocol/virtual-keyboard-unstable-v1.xml virtual-k wayland-scanner client-header protocol/virtual-keyboard-unstable-v1.xml virtual-keyboard-unstable-v1-client-protocol.h # shellcheck disable=SC2046 # pkg-config emits compiler argument words. cc -O2 '-DVERSION="0.4"' -I. main.c virtual-keyboard-unstable-v1-protocol.c $(pkg-config --cflags --libs wayland-client wayland-cursor xkbcommon) -lrt -o "$ORCHARD_WORK/wtype" -readelf -h "$ORCHARD_WORK/wtype" | grep -q 'Machine:.*AArch64' +test "$(uname -m)" = "$OMARCHY_MACHINE" cp LICENSE "$ORCHARD_WORK/LICENSE" cp protocol/virtual-keyboard-unstable-v1.xml "$ORCHARD_WORK/protocol.xml" ''' - buildenv.run(args.image, ['sh', '-ec', script], [(work, '/work', False)]) - image = buildenv.identity(args.image) + buildenv.run(image, ['sh', '-ec', script], [(work, '/work', False)], + env={'OMARCHY_MACHINE': machine(args.architecture)}, arch=podman_arch(args.architecture)) + check_elf(work / 'wtype', args.architecture, 'cursor helper') + image_id = buildenv.identity(image) result = work / 'result' result.mkdir() for name in ('wtype', 'LICENSE'): @@ -63,7 +68,7 @@ cp protocol/virtual-keyboard-unstable-v1.xml "$ORCHARD_WORK/protocol.xml" protocol_license = ET.parse(work / 'protocol.xml').getroot().find('copyright').text.strip() with (result / 'LICENSE').open('a') as notices: notices.write('\nVirtual keyboard protocol:\n\n' + protocol_license + '\n') - record = {'schema': 1, 'architecture': 'aarch64', 'source': pin, 'compiler_image': image, + record = {'schema': 1, 'architecture': args.architecture, 'source': pin, 'compiler_image': image_id, 'files': {name: sha(result / name) for name in ('wtype', 'LICENSE')}} (result / 'manifest.json').write_text(json.dumps(record, indent=2) + '\n') output.parent.mkdir(parents=True, exist_ok=True) diff --git a/desktop/build-labwc b/desktop/build-labwc index 56da3cb..95b4d39 100644 --- a/desktop/build-labwc +++ b/desktop/build-labwc @@ -10,6 +10,7 @@ import subprocess import tarfile import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, machine, podman_arch, builder, source_pins, check_package import urllib.request HERE = Path(__file__).resolve().parent @@ -23,10 +24,13 @@ def sha(path): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True, type=Path) + parser.add_argument('--architecture', default='aarch64', choices=('aarch64', 'armv7h')) parser.add_argument('--archive', type=Path) + parser.add_argument('--builder-image', help='existing target compiler image') args = parser.parse_args() if args.output.exists(): parser.error('Choose a new Labwc output directory') + image = builder(buildenv, args.architecture, args.builder_image, labwc=True) pin = json.loads((HERE / 'labwc/source.json').read_text()) patch = HERE / 'labwc/0001-cancel-super-tap-on-pointer.patch' if sha(patch) != pin['patch_sha256']: @@ -34,8 +38,7 @@ def main(): data = args.archive.read_bytes() if args.archive else urllib.request.urlopen(pin['url'], timeout=30).read() if hashlib.sha256(data).hexdigest() != pin['sha256']: parser.error('Labwc source differs from pin') - buildenv.build(IMAGE, HERE / 'labwc/Containerfile', HERE / 'labwc') - image_id = buildenv.identity(IMAGE) + image_id = buildenv.identity(image) args.output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=args.output.parent, prefix='.labwc-') as directory: work = Path(directory).resolve() @@ -55,7 +58,7 @@ def main(): subprocess.run(['patch', '-p1', '-i', str(patch.resolve())], cwd=source, check=True) recipe = ''' set -eu -test "$(uname -m)" = aarch64 +test "$(uname -m)" = "$OMARCHY_MACHINE" meson setup "$ORCHARD_WORK/build" "$ORCHARD_SOURCE" --buildtype=release --wrap-mode=nofallback -Dman-pages=disabled -Dlabnag=disabled -Dxwayland=enabled -Dnls=enabled -Dsystemd-session=disabled meson compile -C "$ORCHARD_WORK/build" -j2 cp "$ORCHARD_WORK/build/labwc" "$ORCHARD_OUTPUT/labwc" @@ -63,12 +66,14 @@ pacman -Q > "$ORCHARD_OUTPUT/packages.lock" cc --version > "$ORCHARD_OUTPUT/compiler.txt" "$ORCHARD_OUTPUT/labwc" --version > "$ORCHARD_OUTPUT/version.txt" ''' - buildenv.run(IMAGE, ['sh', '-c', recipe], - [(source, '/source', True), (output, '/output', False)], root=True) + buildenv.run(image, ['sh', '-c', recipe], + [(source, '/source', True), (output, '/output', False)], root=True, + env={'OMARCHY_MACHINE': machine(args.architecture)}, arch=podman_arch(args.architecture)) + check_elf(output / 'labwc', args.architecture, 'Labwc') shutil.copyfile(source / 'LICENSE', output / 'LICENSE') shutil.copyfile(patch, output / '0001-cancel-super-tap-on-pointer.patch') (output / 'source.tar.gz').write_bytes(data) - record = {'schema': 1, 'source': pin, 'compiler_image': image_id, + record = {'schema': 1, 'architecture': args.architecture, 'source': pin, 'compiler_image': image_id, 'build_options': ['release', 'nofallback', 'xwayland=enabled', 'nls=enabled', 'labnag=disabled'], 'files': {p.name: sha(p) for p in sorted(output.iterdir())}} (output / 'manifest.json').write_text(json.dumps(record, indent=2) + '\n') diff --git a/desktop/build-sources b/desktop/build-sources index 9cc0f4a..8ba134d 100755 --- a/desktop/build-sources +++ b/desktop/build-sources @@ -9,6 +9,7 @@ import shutil import subprocess import tempfile from snapshot_buildenv import buildenv +from target import validate, check_elf, machine, podman_arch, builder, source_pins, check_package from urllib.error import HTTPError, URLError from urllib.request import urlopen @@ -52,19 +53,22 @@ def fetch(entry, target): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--architecture', default='aarch64', choices=('aarch64', 'armv7h')) + parser.add_argument('--builder-image', help='existing target compiler image') args = parser.parse_args() output = args.output.resolve() if output.exists(): parser.error(f'Output exists: {output}') if not buildenv.native() and not shutil.which('podman'): parser.error('Podman is required') - pins = json.loads((HERE / 'sources.json').read_text()) + pins = source_pins(args.architecture) pkgbuild = HERE / 'packages/omafox/PKGBUILD' if pins['omafox']['sha256'] not in pkgbuild.read_text(): parser.error('Omafox PKGBUILD and source pin differ') wlroots = HERE / 'packages/wlroots0.20/PKGBUILD' if pins['wlroots']['sha256'] not in wlroots.read_text(): parser.error('wlroots PKGBUILD and source pin differ') + image = builder(buildenv, args.architecture, args.builder_image) (HERE / '.work').mkdir(exist_ok=True) try: with tempfile.TemporaryDirectory(prefix='sources-', dir=HERE / '.work') as temporary: @@ -76,11 +80,12 @@ def main(): 'omafox': fetch(pins['omafox'], sources / 'omafox-1.1.0.tar.gz'), 'wlroots': fetch(pins['wlroots'], sources / 'wlroots-0.20.2.tar.gz'), } - print('Preparing AArch64 desktop builder...', flush=True) - buildenv.build(IMAGE, HERE / 'Containerfile', HERE) + if args.architecture == 'armv7h': + fetched['omacalc'] = fetch(pins['omacalc'], sources / 'omacalc-0.3.1.tar.gz') print('Building keyboard and Omafox package...', flush=True) script = r''' set -euo pipefail +test "$(uname -m)" = "$OMARCHY_MACHINE" build=$(mktemp -d "$ORCHARD_TMP/desktop.XXXXXX") trap 'rm -rf "$build"' EXIT mkdir -p "$build/keyboard" "$build/omafox" "$build/result" "$ORCHARD_WORK/result" @@ -93,35 +98,56 @@ fi tar -xzf "$ORCHARD_WORK/sources/wvkbd-v0.20.tar.gz" --strip-components=1 -C "$build/keyboard" patch --batch --fuzz=0 -d "$build/keyboard" -p1 < "$ORCHARD_RECIPE/keyboard/wvkbd-0.20.patch" make -C "$build/keyboard" -j2 wvkbd-mobintl -readelf -h "$build/keyboard/wvkbd-mobintl" | grep -q 'Machine:.*AArch64' +readelf -h "$build/keyboard/wvkbd-mobintl" >/dev/null cp "$build/keyboard/wvkbd-mobintl" "$ORCHARD_WORK/result/wvkbd-mobintl" cp "$ORCHARD_RECIPE/packages/omafox/PKGBUILD" "$ORCHARD_RECIPE/packages/omafox/omafox.install" "$build/omafox/" cp "$ORCHARD_WORK/sources/omafox-1.1.0.tar.gz" "$build/omafox/omafox-1.1.0.tar.gz" cd "$build/omafox" -PKGDEST="$build/result" makepkg --noconfirm --nodeps -package="$build/result/omafox-1.1.0-1-aarch64.pkg.tar.xz" +PKGDEST="$build/result" PKGEXT='.pkg.tar.xz' makepkg --noconfirm --nodeps +package="$build/result/omafox-1.1.0-1-$OMARCHY_ARCH.pkg.tar.xz" test -s "$package" -bsdtar -xOf "$package" .PKGINFO | grep -x 'arch = aarch64' >/dev/null +bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $OMARCHY_ARCH" >/dev/null cp "$package" "$ORCHARD_WORK/result/omafox.pkg.tar.xz" mkdir -p "$build/wlroots" cp "$ORCHARD_RECIPE"/packages/wlroots0.20/* "$build/wlroots/" cp "$ORCHARD_WORK/sources/wlroots-0.20.2.tar.gz" "$build/wlroots/" cd "$build/wlroots" -PKGDEST="$build/result" makepkg --noconfirm --nodeps -package=$(ls "$build"/result/wlroots0.20-0.20.2-*-aarch64.pkg.tar.*) +PKGDEST="$build/result" PKGEXT='.pkg.tar.xz' makepkg --noconfirm --nodeps +package=$(ls "$build"/result/wlroots0.20-0.20.2-*-$OMARCHY_ARCH.pkg.tar.*) test -s "$package" -bsdtar -xOf "$package" .PKGINFO | grep -x 'arch = aarch64' >/dev/null +bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $OMARCHY_ARCH" >/dev/null cp "$package" "$ORCHARD_WORK/result/wlroots0.20.pkg.tar.xz" ''' - buildenv.run(IMAGE, ['bash', '-euc', script], - [(work, '/work', False), (HERE, '/recipe', True)]) + if args.architecture == 'armv7h': + script += r''' +mkdir -p "$build/omacalc" +cp "$ORCHARD_RECIPE/packages/omacalc/PKGBUILD" "$build/omacalc/" +cp "$ORCHARD_WORK/sources/omacalc-0.3.1.tar.gz" "$build/omacalc/" +cd "$build/omacalc" +QT_QPA_PLATFORM=offscreen PKGDEST="$build/result" PKGEXT='.pkg.tar.zst' makepkg --noconfirm --nodeps +package="$build/result/omacalc-0.3.1-1-$OMARCHY_ARCH.pkg.tar.zst" +bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $OMARCHY_ARCH" >/dev/null +cp "$package" "$ORCHARD_WORK/result/omacalc.pkg.tar.zst" +''' + buildenv.run(image, ['bash', '-euc', script], + [(work, '/work', False), (HERE, '/recipe', True)], + env={'OMARCHY_MACHINE': machine(args.architecture), 'OMARCHY_ARCH': args.architecture}, + arch=podman_arch(args.architecture)) built = work / 'result' - files = {name: sha(built / name) for name in ('wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz')} - meta = {'architecture': 'aarch64', 'source_urls': fetched, + check_elf(built / 'wvkbd-mobintl', args.architecture, 'keyboard') + names = ['wvkbd-mobintl', 'omafox.pkg.tar.xz', 'wlroots0.20.pkg.tar.xz'] + if args.architecture == 'armv7h': + names.append('omacalc.pkg.tar.zst') + for name in names[1:]: + check_package(built / name, args.architecture) + files = {name: sha(built / name) for name in names} + meta = {'architecture': args.architecture, 'source_urls': fetched, 'source_sha256': {name: pins[name]['sha256'] for name in pins}, 'patch_sha256': sha(HERE / 'keyboard/wvkbd-0.20.patch'), 'pkgbuild_sha256': sha(pkgbuild), 'wlroots_recipe_sha256': recipe_sha(HERE / 'packages/wlroots0.20'), 'files': files} + if args.architecture == 'armv7h': + meta['omacalc_recipe_sha256'] = recipe_sha(HERE / 'packages/omacalc') (built / 'manifest.json').write_text(json.dumps(meta, indent=2) + '\n') output.parent.mkdir(parents=True, exist_ok=True) os.replace(built, output) diff --git a/desktop/install-toolchain b/desktop/install-toolchain index 6cba335..42102ab 100644 --- a/desktop/install-toolchain +++ b/desktop/install-toolchain @@ -12,7 +12,7 @@ rank-mirrors pacman-key --init pacman-key --populate archlinuxarm # The base image's kernel and firmware are not needed to build: never download them. -mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-firmware.*)$') +mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-armv7|linux-firmware.*)$') (( ${#unneeded[@]} == 0 )) || pacman -Rdd --noconfirm "${unneeded[@]}" packages=(base-devel wayland libxkbcommon pango scdoc pkgconf patch python python-pip git curl tar gzip # wlroots0.20 (packages/wlroots0.20): Arch's makedepends and the libraries it links diff --git a/desktop/labwc/install-toolchain b/desktop/labwc/install-toolchain index 057d973..ad7e6bb 100644 --- a/desktop/labwc/install-toolchain +++ b/desktop/labwc/install-toolchain @@ -5,7 +5,7 @@ set -euo pipefail # also in CI). rank-mirrors # The base image's kernel and firmware are not needed to build: never download them. -mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-firmware.*)$') +mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-armv7|linux-firmware.*)$') (( ${#unneeded[@]} == 0 )) || pacman -Rdd --noconfirm "${unneeded[@]}" packages=(meson wlroots0.20 wayland-protocols libsfdo libxml2 glib2 cairo pango librsvg xcb-util-wm xorg-xwayland gettext) diff --git a/desktop/packages/omacalc/PKGBUILD b/desktop/packages/omacalc/PKGBUILD new file mode 100644 index 0000000..485388d --- /dev/null +++ b/desktop/packages/omacalc/PKGBUILD @@ -0,0 +1,36 @@ +# Omarchy A: ARMv7 build of Omacalc's pinned, MIT-licensed source. +# Corresponding source: omacom/omacalc v0.3.1, commit 573d7106f5c6c0225864eb8634ae37ddcb657f63. +pkgname=omacalc +pkgver=0.3.1 +pkgrel=1 +pkgdesc="Omarchy calculator" +arch=('armv7h') +url='https://github.com/omacom/omacalc' +license=('MIT' 'OFL-1.1') +depends=('qt6-base' 'qt6-declarative' 'xdg-desktop-portal') +makedepends=('qt6-tools') +_commit=573d7106f5c6c0225864eb8634ae37ddcb657f63 +source=("$pkgname-$pkgver.tar.gz::$url/archive/$_commit.tar.gz") +sha256sums=('6a4ad731acb4f68226b975e1b817b2907fea1534d52d1e769564a76a48f3eafc') + +build() { + cd "$srcdir/omacalc-$_commit" + mkdir -p build + cd build + qmake6 ../omacalc.pro + make +} + +check() { + cd "$srcdir/omacalc-$_commit" + sh bin/test +} + +package() { + cd "$srcdir/omacalc-$_commit" + install -Dm755 build/omacalc "$pkgdir/usr/bin/omacalc" + install -Dm644 pkgbuild/omacalc.desktop "$pkgdir/usr/share/applications/omacalc.desktop" + install -Dm644 pkgbuild/omacalc.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/omacalc.svg" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/omacalc/LICENSE" + install -Dm644 fonts/OFL.txt "$pkgdir/usr/share/licenses/omacalc/OFL.txt" +} diff --git a/desktop/packages/omafox/PKGBUILD b/desktop/packages/omafox/PKGBUILD index cad6390..0deed8a 100644 --- a/desktop/packages/omafox/PKGBUILD +++ b/desktop/packages/omafox/PKGBUILD @@ -4,7 +4,7 @@ pkgname=omafox pkgver=1.1.0 pkgrel=1 pkgdesc='Synchronize Firefox and Zen Browser themes with Omarchy' -arch=('x86_64' 'aarch64') +arch=('x86_64' 'aarch64' 'armv7h') url='https://github.com/cover/omafox' license=('MIT') depends=('bash' 'glibc') diff --git a/desktop/packages/wlroots0.20/PKGBUILD b/desktop/packages/wlroots0.20/PKGBUILD index 79b2039..9ae4174 100644 --- a/desktop/packages/wlroots0.20/PKGBUILD +++ b/desktop/packages/wlroots0.20/PKGBUILD @@ -10,7 +10,7 @@ pkgrel=1.6 pkgdesc="Modular Wayland compositor library" url="https://gitlab.freedesktop.org/wlroots/wlroots" license=('MIT') -arch=('x86_64' 'aarch64') +arch=('x86_64' 'aarch64' 'armv7h') depends=('glibc' 'libdisplay-info.so' 'libdrm' 'libglvnd' 'libinput' 'liblcms2.so' 'libliftoff.so' 'libpixman-1.so' 'libseat.so' 'libudev.so' 'libvulkan.so' 'libwayland-client.so' 'libwayland-server.so' 'libxcb' 'libxkbcommon.so' 'wayland-protocols' 'opengl-driver' 'xcb-util-errors' 'xcb-util-renderutil' 'xcb-util-wm') diff --git a/desktop/rank-mirrors b/desktop/rank-mirrors index 34fa8c9..c637a79 100755 --- a/desktop/rank-mirrors +++ b/desktop/rank-mirrors @@ -24,6 +24,7 @@ candidates=( mirrors.tuna.tsinghua.edu.cn/archlinuxarm mirrors.ustc.edu.cn/archlinuxarm ) arch=$(uname -m) +case "$arch" in armv7l|armv7|armhf) arch=armv7h;; esac work=$(mktemp -d) trap 'rm -rf -- "$work"' EXIT diff --git a/desktop/stage.py b/desktop/stage.py index ad7fb38..9edc0ed 100644 --- a/desktop/stage.py +++ b/desktop/stage.py @@ -10,8 +10,12 @@ import json from pathlib import Path import shutil +import sys HERE = Path(__file__).resolve().parent +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) +from target import check_elf PORT = HERE / "shell" CONFIG = HERE / "configuration" INPUTS = json.loads((HERE / 'arm64/inputs.json').read_text()) @@ -36,12 +40,14 @@ def main(): parser.add_argument("--keyboard", type=Path, required=True, help="built wvkbd-mobintl binary") parser.add_argument("--keyboard-sha256", required=True, help="hash from the source-build manifest") parser.add_argument("--omafox-xpi", type=Path, required=True, help="signed Omafox extension") + parser.add_argument("--architecture", default="aarch64", choices=("aarch64", "armv7h")) args = parser.parse_args() FORK, KEYBOARD, XPI = args.upstream, args.keyboard, args.omafox_xpi target = args.target.resolve() if target.exists() or target == Path("/") or target.is_relative_to(Path("/Volumes")): parser.error("target must be a new local staging directory, not an existing/mounted volume") checked(KEYBOARD, args.keyboard_sha256) + check_elf(KEYBOARD, args.architecture, "keyboard") checked(XPI, XPI_SHA) source = target / "home/tablet/.local/share/omarchy-ipad" # Copy shell sources and immutable upstream theme assets. diff --git a/desktop/target.py b/desktop/target.py new file mode 100644 index 0000000..7c88e2d --- /dev/null +++ b/desktop/target.py @@ -0,0 +1,75 @@ +"""Desktop build targets, checked against component bytes and package metadata.""" +from pathlib import Path +import json +import platform +import subprocess +import sys +import tarfile + +PROJECT = Path(__file__).resolve().parents[1] +if str(PROJECT) not in sys.path: + sys.path.insert(0, str(PROJECT)) +from release.architecture import validate, check_elf, machine, podman_arch + + +def source_pins(architecture): + validate(architecture) + here = Path(__file__).resolve().parent + pins = json.loads((here / 'sources.json').read_text()) + if architecture == 'armv7h': + pins.update(json.loads((here / 'armv7/sources.json').read_text())) + return pins + + +def builder(buildenv, architecture, image=None, *, labwc=False): + validate(architecture) + if buildenv.native(): + if platform.machine() != machine(architecture): + raise ValueError('Native compiler architecture differs from ' + architecture) + return image or 'native:' + architecture + if image: + return image + if architecture == 'armv7h': + raise ValueError('ARMv7 desktop builds require --builder-image with an ARMv7 Arch Linux ARM compiler') + here = Path(__file__).resolve().parent + image = 'localhost/omarchy-a-' + ('labwc' if labwc else 'desktop') + '-builder:arm64' + folder = here / 'labwc' if labwc else here + buildenv.build(image, folder / 'Containerfile', folder, arch=podman_arch(architecture)) + return image + + +def check_package(path, architecture): + """A package's signed/hash-checked bytes must name the selected target.""" + validate(architecture) + result = subprocess.run(['bsdtar', '-xOf', str(path), '.PKGINFO'], check=True, + text=True, capture_output=True, timeout=30) + values = [line.removeprefix('arch = ') for line in result.stdout.splitlines() if line.startswith('arch = ')] + if values != [architecture]: + raise ValueError('Package architecture differs from ' + architecture + ': ' + str(path)) + # Re-stream the package as tar so Python versions without zstd support + # inspect ELF headers without extracting files onto the host. + process = subprocess.Popen(['bsdtar', '-cf', '-', '@' + str(Path(path).resolve())], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + expanded = count = 0 + with tarfile.open(fileobj=process.stdout, mode='r|') as archive: + for member in archive: + count += 1 + expanded += member.size + if count > 100000 or expanded > (1 << 30): + raise ValueError('Desktop package exceeds its inspection bound') + if member.isfile(): + with archive.extractfile(member) as source: + header = source.read(64) + if header.startswith(b'\x7fELF'): + check_elf(header, architecture, member.name) + process.stdout.close() + if process.wait(timeout=30): + raise ValueError('Desktop package could not be inspected') + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + process.stdout.close() + process.stderr.close() + return architecture diff --git a/orchard.toml b/orchard.toml index 89cf802..3910934 100644 --- a/orchard.toml +++ b/orchard.toml @@ -5,3 +5,4 @@ name = "omarchy-a" [release] build = "tools/build.py" selects-platform = true # tools/build.py takes --platform NAME ... or --platform all +selects-device = true # Cascadia payloads use the selected board's handoff profile diff --git a/release/architecture.py b/release/architecture.py new file mode 100644 index 0000000..344434e --- /dev/null +++ b/release/architecture.py @@ -0,0 +1,68 @@ +"""Userspace targets and executable checks shared by the release producers.""" +from pathlib import Path +import struct +import tomllib + + +TARGETS = { + 'aarch64': {'orchard': 'arm64', 'machine': 'aarch64', 'class': 2, 'elf_machine': 183}, + 'armv7h': {'orchard': 'arm', 'machine': 'armv7l', 'class': 1, 'elf_machine': 40}, +} + + +def validate(architecture): + if architecture not in TARGETS: + raise ValueError(f'Unsupported userspace architecture: {architecture}') + return architecture + + +def orchard_arch(architecture): + return TARGETS[validate(architecture)]['orchard'] + + +def machine(architecture): + return TARGETS[validate(architecture)]['machine'] + + +def podman_arch(architecture): + return orchard_arch(architecture) + + +def platform_arch(platform, snapshot=None): + """The userspace ISA for PLATFORM, checked against its Orchard descriptor.""" + if snapshot is not None: + if not isinstance(platform, str) or '/' in platform or platform in ('', '.', '..'): + raise ValueError('Invalid platform name') + path = Path(snapshot) / 'platforms' / (platform + '.toml') + arch = tomllib.loads(path.read_text())['kernel']['arch'] + for name, target in TARGETS.items(): + if target['orchard'] == arch: + return name + raise ValueError(f'{platform}: unsupported kernel architecture {arch}') + known = {'apple-16k': 'aarch64', 'apple-4k': 'aarch64', 'apple-armv7': 'armv7h'} + if platform not in known: + raise ValueError(f'Unknown platform architecture: {platform}') + return known[platform] + + +def check_elf(data, architecture, label='binary', *, executable=True): + """Require an executable/shared ELF for the selected ISA and hard-float ABI.""" + target = TARGETS[validate(architecture)] + if isinstance(data, (str, Path)): + with Path(data).open('rb') as stream: + data = stream.read(64) + minimum = 64 if target['class'] == 2 else 52 + if (len(data) < minimum or data[:4] != b'\x7fELF' or + data[4] != target['class'] or data[5:7] != b'\x01\x01'): + raise ValueError(f'{label}: expected little-endian {architecture} ELF') + kind, elf_machine, version = struct.unpack_from('> 24 != 5 or (kind != 1 and (not flags & 0x400 or flags & 0x200)): + raise ValueError(f'{label}: expected ARM EABI5 hard-float executable') + return architecture diff --git a/release/audit.py b/release/audit.py index 7d19b57..13a5c00 100644 --- a/release/audit.py +++ b/release/audit.py @@ -4,13 +4,159 @@ import json from pathlib import Path, PurePosixPath import subprocess +import struct import tarfile +import zlib from release import snapshot from release.manifests import read_release from .common import PROJECT from .public_root import sha +from .recovery_audit import audit_runtime +from release.architecture import validate, check_elf + +# m1n1's payload framing (orchard docs/ram-development.md, "Assembling by hand"). +INITRAMFS_MAGIC = b'm1n1_initramfs' +LOGO_MAGIC = b'm1n1_logo_256128' +CASCADE_PADDING = {'loader-padding', 'kernel-padding', 'dtb-padding'} + + +def dtb_initrd(data): + """Read /chosen's initrd range from the checked binary tree framing.""" + if len(data) < 40: + raise ValueError('Truncated Cascadia DTB') + magic, total, tree, strings, _, version, last, _, names_size, tree_size = struct.unpack_from('>10I', data) + if (magic != 0xd00dfeed or total != len(data) or version < 17 or last > 17 or + any(start < 40 or size <= 0 or start + size > total + for start, size in ((tree, tree_size), (strings, names_size)))): + raise ValueError('Invalid Cascadia DTB bounds') + names, end, stack, values, cells, finished = data[strings:strings + names_size], tree + tree_size, [], {}, 2, False + while tree < end: + if tree + 4 > end: + raise ValueError('Truncated Cascadia DTB token') + token = struct.unpack_from('>I', data, tree)[0] + tree += 4 + if token == 1: + stop = data.find(b'\0', tree, end) + if stop < 0: + raise ValueError('Unterminated Cascadia DTB node') + stack.append(data[tree:stop]) + tree = (stop + 4) & ~3 + elif token == 2: + if not stack: + raise ValueError('Unbalanced Cascadia DTB') + stack.pop() + elif token == 3: + if not stack or tree + 8 > end: + raise ValueError('Truncated Cascadia DTB property') + length, at = struct.unpack_from('>II', data, tree) + tree += 8 + stop = names.find(b'\0', at) + if at >= len(names) or stop < 0 or tree + length > end: + raise ValueError('Invalid Cascadia DTB property') + name, value = names[at:stop], data[tree:tree + length] + if stack == [b''] and name == b'#address-cells': + if len(value) != 4 or (cells := int.from_bytes(value, 'big')) not in (1, 2): + raise ValueError('Invalid Cascadia address cells') + if stack == [b'', b'chosen'] and name in (b'linux,initrd-start', b'linux,initrd-end'): + if name in values: + raise ValueError('Duplicate Cascadia initrd property') + values[name] = value + tree = (tree + length + 3) & ~3 + elif token == 4: + continue + elif token == 9: + if stack or tree != end: + raise ValueError('Cascadia DTB ends inside a node') + finished = True + break + else: + raise ValueError('Unknown Cascadia DTB token') + if not finished or len(values) != 2 or any(len(value) != cells * 4 for value in values.values()): + raise ValueError('Cascadia DTB lacks its initrd range') + return tuple(int.from_bytes(values[key], 'big') for key in (b'linux,initrd-start', b'linux,initrd-end')) + + +def audit_cascadia(image, parts, receipt): + required = {'loader', 'kernel', 'dtb', 'initramfs'} + if (receipt.get('architecture') != 'arm' or receipt.get('initramfs_encoding') != 'cascadia-dtb' or + set(parts) - CASCADE_PADDING != required): + raise ValueError('Invalid Cascadia recovery framing') + loader = parts['loader'][0] + if loader['offset'] or loader['bytes'] < 0x7f00 + 68 or loader['bytes'] > 0x8000: + raise ValueError('Invalid Cascadia loader size') + with image.open('rb') as stream: + if stream.read(4) != bytes.fromhex('c0000cf1'): + raise ValueError('Cascadia loader entry differs') + stream.seek(0x7f00) + raw = stream.read(68) + magic, version, size, base, kernel, dtb, dtb_size, initrd, initrd_size, target, image_size, _, _, ram_first, ram_end, limit, crc = struct.unpack('<4s16I', raw) + if magic != b'CB32' or version != 1 or size != 68 or zlib.crc32(raw[:-4]) != crc: + raise ValueError('Invalid Cascadia loader configuration') + if (kernel != 0x8000 or base % 0x8000 or target % 4096 or + not ram_first <= base < base + image_size <= target < limit <= ram_end or + target + initrd_size > limit or image_size != image.stat().st_size or + receipt.get('load_address') != base or receipt.get('kernel_entry') != base + kernel or + receipt.get('dtb_address') != base + dtb or receipt.get('initramfs_address') != target): + raise ValueError('Cascadia handoff addresses differ') + for name, offset, length in (('kernel', kernel, None), ('dtb', dtb, dtb_size), ('initramfs', initrd, initrd_size)): + row = parts[name][0] + if row['offset'] != offset or (length is not None and row['bytes'] != length): + raise ValueError('Cascadia configuration differs from component: ' + name) + kernel_header = parts['kernel'][1] + if (len(kernel_header) < 48 or struct.unpack_from(' 256 * 1024**2 or len(seen) > 100000: + raise ValueError('Recovery member exceeds limits') + label = stream.read(name_size) + if len(label) != name_size or label[-1:] != b'\0' or b'\0' in label[:-1]: + raise ValueError('Invalid recovery member name') + name = label[:-1].decode() + stream.read(-(110 + name_size) % 4) + if name == 'TRAILER!!!': + if size or stream.read(513) != b'\0' * 512: + raise ValueError('Unexpected recovery trailer data') + return + path = PurePosixPath(name) + if str(path) == '.' or path.is_absolute() or '..' in path.parts or name in seen or fields[2:4] != [0, 0]: + raise ValueError('Unsafe/duplicate/non-root recovery member') + if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode) or stat.S_ISLNK(mode)): + raise ValueError('Special recovery member') + seen.add(name) + data = stream.read(size) + if len(data) != size: + raise ValueError('Truncated recovery member') + stream.read(-size % 4) + total += size + if total > 1024**3: + raise ValueError('Recovery expansion exceeds limit') + yield name, mode, data + + +def audit_runtime(image, kernel_release, release, private): + """The recovery's runtime: endpoints, this release, KERNEL_RELEASE's modules, no PRIVATE firmware.""" + receipt = json.loads(Path(str(image) + '.json').read_text()) + part = next(row for row in receipt['components'] if row['name'] == 'initramfs') + hashes, modes, records = {}, {}, {} + names = set() + with image.open('rb') as raw: + raw.seek(part['offset']) + with gzip.GzipFile(fileobj=raw) as stream: + for name, mode, data in read_newc(stream): + names.add(name) + modes[name] = mode + if name.removeprefix('usr/') in private: + raise ValueError('Private firmware found in recovery') + if stat.S_ISREG(mode): + if name.startswith(('run/orchard/', 'run/recovery/', 'root/.ssh/')): + raise ValueError('Private session state found in public recovery') + hashes[name] = hashlib.sha256(data).hexdigest() + if name in ('etc/orchard/release.json', 'etc/orchard/public.SHA256SUMS'): + records[name] = data + prefix = 'usr/libexec/orchard/' + required = {prefix + name for name in ('device.py', 'identity.py', 'storage-fence', 'adt.py')} + required |= {'init', 'usr/bin/orchard-device', 'sbin/capture-adt', 'sbin/authorize-key', + 'sbin/recovery-start-ssh', 'usr/bin/python3', + 'usr/sbin/blkid', 'usr/sbin/sfdisk', 'usr/sbin/mke2fs', 'etc/mke2fs.conf'} + if not required <= names: + raise ValueError('Missing recovery endpoint/runtime') + for name in ('init', 'usr/bin/orchard-device', 'sbin/capture-adt', 'sbin/authorize-key', 'sbin/recovery-start-ssh', + prefix + 'storage-fence', 'usr/sbin/blkid', 'usr/sbin/sfdisk', 'usr/sbin/mke2fs'): + if not stat.S_ISREG(modes[name]) or modes[name] & 0o111 != 0o111: + raise ValueError('Recovery helper is not executable: ' + name) + if any('omarchy-j71b' in name or 'orchard-j71b' in name or name == 'usr/bin/omarchy-device' for name in names): + raise ValueError('Old agent namespace remains in recovery') + # The host checks the root's hash; the recovery only has to belong to this + # release and run the kernel of its platform's layer. + config = json.loads(records['etc/orchard/release.json']) + if (config['release'] != release or config['protocol'] != 1 or + config['kernel_release'] != kernel_release): + raise ValueError('Recovery does not belong to this release') + public = records['etc/orchard/public.SHA256SUMS'].decode() + for line in public.splitlines(): + digest, name = line.split(' ', 1) + if hashes.get(name.lstrip('/')) != digest: + raise ValueError('Recovery public runtime differs: ' + name) + return {'members': len(names), 'hashes': hashes, 'public': public} diff --git a/system/assemble-rootfs b/system/assemble-rootfs index c5ee3c2..234594f 100755 --- a/system/assemble-rootfs +++ b/system/assemble-rootfs @@ -1,27 +1,36 @@ #!/usr/bin/env bash set -euo pipefail -root= output= desktop= release= +root= output= desktop= release= architecture=aarch64 while (($#)); do case "$1" in --root) root=$2; shift 2;; --output) output=$2; shift 2;; --desktop) desktop=$2; shift 2;; --release) release=$2; shift 2;; + --architecture) architecture=$2; shift 2;; *) echo "Unknown argument: $1" >&2; exit 2;; esac done for value in "$root" "$output" "$desktop" "$release"; do [[ -n "$value" ]] || { echo 'Missing required build input' >&2; exit 2; } done -[[ $(uname -m) == aarch64 && $EUID == 0 ]] || { echo 'Requires an aarch64 Arch Linux ARM build environment as root' >&2; exit 1; } -for cmd in pacman useradd usermod systemctl tar python3; do command -v "$cmd" >/dev/null || { echo "Missing $cmd" >&2; exit 1; }; done +case "$architecture" in + aarch64) expected_machine=aarch64;; + armv7h) expected_machine=armv7l;; + *) echo "Unsupported userspace architecture: $architecture" >&2; exit 2;; +esac +[[ $(uname -m) == "$expected_machine" && $EUID == 0 ]] || { echo "Requires an $expected_machine Arch Linux ARM build environment as root" >&2; exit 1; } +for cmd in pacman useradd usermod systemctl tar bsdtar python3; do command -v "$cmd" >/dev/null || { echo "Missing $cmd" >&2; exit 1; }; done project=$(cd "$(dirname "$0")/.." && pwd) root=$(realpath -m -- "$root") output=$(realpath -m -- "$output") [[ "$root" != / && ! -e "$root" && ! -e "$output" ]] || { echo 'Root and archive must be new paths' >&2; exit 1; } mapfile -t requested < <(grep -vE '^\s*(#|$)' "$project/system/packages.txt") [[ ${#requested[@]} -gt 0 ]] || exit 1 -printf '%s\n' "${requested[@]}" | grep -qx 'linux-aarch64' && { echo 'Generic kernel is not an input' >&2; exit 1; } +printf '%s\n' "${requested[@]}" | grep -Eq '^linux(-aarch64|-armv7|-rpi)?$' && { echo 'Generic kernel is not an input' >&2; exit 1; } +for package in "$desktop/packages/omafox.pkg.tar.xz" "$desktop/packages/omacalc.pkg.tar.zst" "$desktop/packages/wlroots0.20.pkg.tar.xz"; do + bsdtar -xOf "$package" .PKGINFO | grep -x "arch = $architecture" >/dev/null || { echo "Package architecture differs from $architecture: $package" >&2; exit 1; } +done # Install signed packages into a clean target; no mounts or VM root are needed. # The caller's umask 077 would make pacman's own state (/var/lib/pacman, @@ -70,14 +79,18 @@ tar --hard-dereference --numeric-owner --one-file-system -C "$root" \ --exclude='./dev/*' --exclude='./proc/*' --exclude='./sys/*' \ --exclude='./run/*' --exclude='./tmp/*' --exclude='./var/cache/pacman/pkg/*' \ -cf - . | gzip -1 > "$output" -python3 - "$output" "$release" <<'PY' +python3 - "$output" "$release" "$architecture" "$root" "$project" <<'PY' import hashlib,json,pathlib,sys archive,release=pathlib.Path(sys.argv[1]),sys.argv[2] +sys.path.insert(0,sys.argv[5]) +from release.architecture import check_elf +architecture=sys.argv[3] +check_elf(pathlib.Path(sys.argv[4])/'usr/lib/systemd/systemd',architecture,'root systemd') h=hashlib.sha256() with archive.open('rb') as stream: for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) lock=archive.parent/'packages.lock' -metadata={'release':release,'bytes':archive.stat().st_size,'sha256':h.hexdigest(), +metadata={'release':release,'architecture':architecture,'bytes':archive.stat().st_size,'sha256':h.hexdigest(), 'packages_lock_sha256':hashlib.sha256(lock.read_bytes()).hexdigest()} archive.with_suffix(archive.suffix+'.json').write_text(json.dumps(metadata,indent=2)+'\n') print('Clean rootfs:',archive,'SHA-256',h.hexdigest()) diff --git a/system/build-rootfs b/system/build-rootfs index 987c768..2c31e99 100755 --- a/system/build-rootfs +++ b/system/build-rootfs @@ -4,31 +4,38 @@ umask 077 here=$(cd "$(dirname "$0")" && pwd) project=$(cd "$here/.." && pwd) -output='' desktop='' release='' +output='' desktop='' release='' architecture=aarch64 builder_image='' while (($#)); do case "$1" in --output) output=$2; shift 2;; --desktop) desktop=$2; shift 2;; --release) release=$2; shift 2;; + --architecture) architecture=$2; shift 2;; + --builder-image) builder_image=$2; shift 2;; *) echo "Unknown argument: $1" >&2; exit 2;; esac done if [[ -z "$output" || -z "$release" || ! -d "$desktop/payload" || ! -d "$desktop/packages" || -e "$output" ]]; then - echo 'Usage: ./system/build-rootfs --output DIRECTORY --desktop DIRECTORY --release NAME' >&2 + echo 'Usage: ./system/build-rootfs --output DIRECTORY --desktop DIRECTORY --release NAME [--architecture aarch64|armv7h] [--builder-image IMAGE]' >&2 exit 2 fi +case "$architecture" in + aarch64) expected_machine=aarch64; container_args=(--arch arm64);; + armv7h) expected_machine=armv7l; container_args=(--platform linux/arm/v7);; + *) echo "Unsupported userspace architecture: $architecture" >&2; exit 2;; +esac podman=$(command -v podman || true) if [[ ${ORCHARD_BUILD_NATIVE:-0} != 1 ]]; then [[ -n "$podman" ]] || { echo 'Podman is required' >&2; exit 1; } "$podman" info >/dev/null || { echo 'Podman is unavailable' >&2; exit 1; } else - [[ $EUID == 0 && $(uname -m) == aarch64 ]] || { echo 'Native rootfs assembly requires Linux ARM64 as root' >&2; exit 1; } + [[ $EUID == 0 && $(uname -m) == "$expected_machine" ]] || { echo "Native rootfs assembly requires Linux $expected_machine as root" >&2; exit 1; } fi absolute() { python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$1"; } output=$(absolute "$output") desktop=$(absolute "$desktop") -"$project/desktop/build" --verify "$desktop" +"$project/desktop/build" --verify "$desktop" --architecture "$architecture" mkdir -p "$here/.work/rootfs" stage=$(mktemp -d "$here/.work/rootfs/result.XXXXXX") @@ -45,30 +52,35 @@ trap cleanup EXIT if [[ ${ORCHARD_BUILD_NATIVE:-0} == 1 ]]; then native_work=$(mktemp -d "$here/.work/rootfs/native.XXXXXX") "$here/assemble-rootfs" --root "$native_work/root" \ - --output "$native_work/rootfs.tar.gz" --desktop "$desktop" --release "$release" + --output "$native_work/rootfs.tar.gz" --desktop "$desktop" --release "$release" --architecture "$architecture" cp "$native_work/rootfs.tar.gz" "$native_work/rootfs.tar.gz.json" "$native_work/packages.lock" "$stage/" else - "$podman" build -f "$here/Containerfile" -t localhost/omarchy-a-arch-builder:latest "$here" + if [[ -z "$builder_image" ]]; then + [[ "$architecture" == aarch64 ]] || { echo 'ARMv7 builds require --builder-image with an ARMv7 Arch Linux ARM environment, or ORCHARD_BUILD_NATIVE=1 on ARMv7' >&2; exit 1; } + builder_image=localhost/omarchy-a-arch-builder:latest + "$podman" build -f "$here/Containerfile" -t "$builder_image" "$here" + fi volume_name="omarchy-a-rootfs-${stage##*/}-$$" "$podman" volume create "$volume_name" >/dev/null volume=$volume_name - "$podman" run --rm \ + "$podman" run --rm "${container_args[@]}" \ -v "$volume:/work:rw" \ - -v "$here:/repo/system:ro" -v "$desktop:/inputs/desktop:ro" \ - localhost/omarchy-a-arch-builder:latest \ + -v "$project:/repo:ro" -v "$desktop:/inputs/desktop:ro" \ + "$builder_image" \ /repo/system/assemble-rootfs \ --root /work/root --output /work/rootfs.tar.gz \ - --desktop /inputs/desktop --release "$release" - "$podman" run --rm --network none -v "$volume:/work:ro" \ - localhost/omarchy-a-arch-builder:latest \ + --desktop /inputs/desktop --release "$release" --architecture "$architecture" + "$podman" run --rm "${container_args[@]}" --network none -v "$volume:/work:ro" \ + "$builder_image" \ tar -C /work -cf - rootfs.tar.gz rootfs.tar.gz.json packages.lock \ | tar -C "$stage" -xf - fi -python3 - "$stage" <<'PY' +python3 - "$stage" "$architecture" <<'PY' import hashlib, json, pathlib, sys root = pathlib.Path(sys.argv[1]) archive = root / 'rootfs.tar.gz' receipt = json.loads((root / 'rootfs.tar.gz.json').read_text()) +assert receipt['architecture'] == sys.argv[2] digest = hashlib.sha256() with archive.open('rb') as stream: for block in iter(lambda: stream.read(1024 * 1024), b''): diff --git a/system/install-toolchain b/system/install-toolchain index eb542a4..e368aee 100644 --- a/system/install-toolchain +++ b/system/install-toolchain @@ -12,7 +12,7 @@ rank-mirrors pacman-key --init pacman-key --populate archlinuxarm # The base image's kernel and firmware are not needed to build: never download them. -mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-firmware.*)$') +mapfile -t unneeded < <(pacman -Qq | grep -E '^(linux-aarch64|linux-armv7|linux-firmware.*)$') (( ${#unneeded[@]} == 0 )) || pacman -Rdd --noconfirm "${unneeded[@]}" packages=(arch-install-scripts python) pacman-download -Sy --noconfirm --needed "${packages[@]}" diff --git a/system/rank-mirrors b/system/rank-mirrors index 34fa8c9..c637a79 100755 --- a/system/rank-mirrors +++ b/system/rank-mirrors @@ -24,6 +24,7 @@ candidates=( mirrors.tuna.tsinghua.edu.cn/archlinuxarm mirrors.ustc.edu.cn/archlinuxarm ) arch=$(uname -m) +case "$arch" in armv7l|armv7|armhf) arch=armv7h;; esac work=$(mktemp -d) trap 'rm -rf -- "$work"' EXIT diff --git a/tests/test_architecture.py b/tests/test_architecture.py new file mode 100644 index 0000000..3f49554 --- /dev/null +++ b/tests/test_architecture.py @@ -0,0 +1,144 @@ +"""A release's native components must agree with its userspace ABI.""" +import importlib.util +import json +import os +from pathlib import Path +import struct +import sys +import tempfile +import unittest +from unittest.mock import patch + +from release.architecture import check_elf, orchard_arch, platform_arch + + +PROJECT = Path(__file__).resolve().parents[1] + + +def executable(architecture): + size, elf_class, machine = (64, 2, 183) if architecture == 'aarch64' else (52, 1, 40) + data = bytearray(size) + data[:7] = b'\x7fELF' + bytes((elf_class, 1, 1)) + struct.pack_into('I', 1)}, 'children': []} + data, receipt = cascadia.assemble(bytes(loader), bytes(kernel), cascadia.write_fdt(tree), + gzip.compress(b'070701'), described, 'rdinit=/init') + image = root / 'recovery.bin' + image.write_bytes(data) + Path(str(image) + '.json').write_text(json.dumps(receipt)) + return image, receipt + + def test_cascadia_recovery_audit_checks_actual_component_bytes(self): + with tempfile.TemporaryDirectory() as directory: + image, receipt = self.cascadia_fixture(Path(directory)) + self.assertEqual(audit.audit_recovery(image, 'armv7h')['initramfs_encoding'], 'cascadia-dtb') + kernel = next(row for row in receipt['components'] if row['name'] == 'kernel') + with image.open('r+b') as output: + output.seek(kernel['offset'] + 36) + output.write(b'FAIL') + with self.assertRaisesRegex(ValueError, 'integrity mismatch'): + audit.audit_recovery(image, 'armv7h') + + def test_cascadia_config_is_checked_after_component_hashes_agree(self): + with tempfile.TemporaryDirectory() as directory: + image, receipt = self.cascadia_fixture(Path(directory)) + raw = bytearray(image.read_bytes()) + # Forge a valid configuration CRC naming the wrong DTB position. + struct.pack_into(' 1: - parser.error('--platform all builds every platform and combines with no other') - # orchard.inputs resolves every platform when it is given none. - selected = [part for name in args.platform if name != 'all' for part in ('--platform', name)] + try: + requested = args.platform + if args.device: + board_platform = device_platform(orchard, args.device) + if requested and requested != [board_platform]: + raise ValueError('--device must select its own platform') + requested = [board_platform] + architecture, platforms = targets(orchard, requested, args.architecture) + except (OSError, KeyError, ValueError) as error: + parser.error(str(error)) + selected = [part for name in platforms for part in ('--platform', name)] + target_args = ['--architecture', architecture] + builder_args = ['--builder-image', args.builder_image] if args.builder_image else [] + labwc_image = args.labwc_builder_image or args.builder_image + labwc_args = ['--builder-image', labwc_image] if labwc_image else [] + identity = builder_key(args.builder_image) + compiler_identity = builder_key(labwc_image) desktop, desktop_key = component(PROJECT, 'desktop', INPUTS['desktop'], - lambda path, work: python('desktop/build', '--output', path), artifact='desktop') + lambda path, work: python('desktop/build', '--output', path, *target_args, *builder_args), + artifact='desktop', extra=[architecture, identity]) cursor, _ = component(PROJECT, 'cursor', INPUTS['cursor'], - lambda path, work: python('desktop/build-cursor-helper', '--output', path), - artifact='cursor-tools') + lambda path, work: python('desktop/build-cursor-helper', '--output', path, *target_args, *builder_args), + artifact='cursor-tools', extra=[architecture, identity]) bluetooth, _ = component(PROJECT, 'bluetooth', INPUTS['bluetooth'], lambda path, work: python('desktop/build-bluetooth-tools', '--output', path), artifact='bluetooth-tools') labwc, _ = component(PROJECT, 'labwc', INPUTS['labwc'], - lambda path, work: python('desktop/build-labwc', '--output', path), - artifact='labwc-tools') + lambda path, work: python('desktop/build-labwc', '--output', path, *target_args, *labwc_args), + artifact='labwc-tools', extra=[architecture, compiler_identity]) # The base names the component, not a release: it serves every build with the same inputs. base, _ = component(PROJECT, 'base', INPUTS['base'], lambda path, work: run('system/build-rootfs', '--output', path, '--desktop', desktop, - '--release', 'base-' + desktop_key[:16]), - artifact='base', extra=[desktop_key]) + '--release', 'base-' + desktop_key[:16], *target_args, *builder_args), + artifact='base', extra=[desktop_key, architecture, identity]) # The Omarchy mark is data for orchard's m1n1, which every distribution boots. artwork, _ = component(PROJECT, 'artwork', INPUTS['artwork'], lambda path, work: python('boot/build-artwork', '--output', path), artifact='artwork') # What orchard supplies finished: kernels, m1n1, Pongo, tools (the nightly's when they match). - inputs = PROJECT / 'out/components/.inputs.json' - orchard_command('orchard.inputs', '--output', inputs, *selected) - shared = json.loads(inputs.read_text())['shared'] - tools = shared['tools'] + inputs = PROJECT / f'out/components/.inputs-{architecture}.json' + overrides = [part for option in ('platform_loader', 'platform_tools') for value in getattr(args, option) + for part in ('--' + option.replace('_', '-'), value)] + board = ['--device', args.device] if args.device else [] + orchard_command('orchard.inputs', '--output', inputs, *selected, *board, *overrides) + document = json.loads(inputs.read_text()) + shared = document.get('shared', {}) + first = document['platforms'][platforms[0]] + tools = first.get('tools') or shared['tools'] + loader = first.get('loader', {}).get('file') or shared['m1n1'] + access_tools = ('busybox', 'dropbear', 'dropbearkey') + native_inputs = [part for name in access_tools for part in ('--' + name, tools[name])] + if architecture == 'armv7h': + native_inputs += ['--loader', loader] + native_inputs += [part for name in ('wpa_supplicant', 'wpa_cli', 'iw') + for part in ('--' + name.replace('_', '-'), tools[name])] python('-m', 'release.build', '--release', args.version, '--inputs', inputs, '--base', base / 'rootfs.tar.gz', '--base-receipt', base / 'rootfs.tar.gz.json', - '--splash', artwork / 'splash', '--logo', artwork / 'logo.bin', - *(part for name in ('busybox', 'dropbear', 'dropbearkey') - for part in ('--' + name, tools[name])), + '--splash', artwork / 'splash', '--logo', artwork / 'logo.bin', *native_inputs, '--cursor-tools', cursor, '--bluetooth-tools', bluetooth, '--labwc-tools', labwc, - '--output', args.output) + *target_args, *selected, '--output', args.output) python('-m', 'release.audit', args.output / 'release.toml') # install.sh and orchard-install.pyz, with orchard's launch loaders. orchard_command('orchard.installer', 'release', args.output) From 003bac3cce0b28d84e81855c9809c240ee1c11ef Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:01:22 -0600 Subject: [PATCH 02/14] build: prepare the ARMv7 compiler from a signed Arch root Verify the official bootstrap archive against its pinned hash and published signing key before importing it into the selected Podman connection. Prepare the native compiler and Qt build dependencies, then record the compiler image ID and package versions. The recipe uses an existing ARM32 execution environment and leaves emulator registration and the host default connection to the host owner. Assisted-by: LLM --- desktop/armv7/README.md | 55 +++++++++++++ system/armv7/Containerfile | 12 +++ system/armv7/archlinuxarm-build.asc | 114 ++++++++++++++++++++++++++ system/armv7/bootstrap.json | 11 +++ system/build-armv7-builder | 122 ++++++++++++++++++++++++++++ tests/test_armv7_bootstrap.py | 69 ++++++++++++++++ 6 files changed, 383 insertions(+) create mode 100644 desktop/armv7/README.md create mode 100644 system/armv7/Containerfile create mode 100644 system/armv7/archlinuxarm-build.asc create mode 100644 system/armv7/bootstrap.json create mode 100755 system/build-armv7-builder create mode 100644 tests/test_armv7_bootstrap.py diff --git a/desktop/armv7/README.md b/desktop/armv7/README.md new file mode 100644 index 0000000..c22e84d --- /dev/null +++ b/desktop/armv7/README.md @@ -0,0 +1,55 @@ +# ARMv7 desktop components + +`desktop/build --architecture armv7h` builds the same desktop payload for an +ARMv7 Arch Linux ARM userspace. The existing AArch64 signed Omacalc input +continues to serve AArch64 builds. ARMv7 builds compile Omacalc from the source +recorded in `sources.json`, with the recipe in `../packages/omacalc`. + +The Omacalc archive is pinned to upstream v0.3.1, commit +`573d7106f5c6c0225864eb8634ae37ddcb657f63`. Its SHA-256 was measured from the +265,215-byte GitHub commit archive. The recipe installs upstream's MIT license +and the bundled font's OFL notice, and runs the upstream Qt tests offscreen. + +A provisioned native ARMv7 Arch Linux ARM environment can run the recipes with +`ORCHARD_BUILD_NATIVE=1` and `ORCHARD_BUILD_ENV_ID` set to its reproducible image +identity. Orchard's ARMv7 build environment support is required. Container +builds take `--builder-image IMAGE`, naming an ARMv7 Arch Linux ARM compiler +with the dependencies from `desktop/install-toolchain` and +`desktop/labwc/install-toolchain`, plus `qt6-base`, `qt6-declarative` and +`qt6-tools`. The same option is accepted by the component builders. The +repository's pinned AArch64 container is selected by AArch64 builds. + +`system/build-armv7-builder` prepares that compiler image from the official +Arch Linux ARM bootstrap pinned in `system/armv7/bootstrap.json`. It verifies +the archive's SHA-256 and detached signature against Arch Linux ARM's build +key before importing it. The selected Podman connection must already support +ARM32 execution. An emulator is a host prerequisite; the recipe leaves its +registration and the default connection to the person managing the host. + +```sh +system/build-armv7-builder --receipt out/armv7-builder.json +python3 tools/build.py --orchard /path/to/orchard --device p105 \ + --builder-image localhost/omarchy-a-arch-builder:armv7h \ + --version armv7-development --output out/armv7-development +``` + +For an isolated builder, pass `--connection NAME` to the compiler recipe and +set `CONTAINER_CONNECTION=NAME` for the release build. The compiler receipt +records its image ID and installed package versions. Component cache keys +include the target architecture and the selected compiler image ID. + +Each release contains one userspace ISA. Selecting `apple-armv7` chooses +`armv7h`; mixing it with AArch64 platforms is rejected before components +build. A Cascadia recovery also needs one selected board, because its loader +uses that board's handoff profile. Orchard's companion ARMv7 producer change +provides the loader and static tools through its per-platform input record. + +The producers check the compiler's machine and the resulting ELF headers. +Package verification checks `.PKGINFO` and the ELF headers inside each package, +including the ARM EABI5 hard-float flags. Each component receipt records the +userspace architecture. A foreign package or executable stops assembly before +it reaches a root filesystem. + +These source recipes require a full ARMv7 component build and desktop smoke +check before hardware qualification. Tests of the architecture guards use +synthetic packages and executable headers. diff --git a/system/armv7/Containerfile b/system/armv7/Containerfile new file mode 100644 index 0000000..4f0b6de --- /dev/null +++ b/system/armv7/Containerfile @@ -0,0 +1,12 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +COPY desktop/install-toolchain desktop/rank-mirrors desktop/pacman-download /tmp/desktop/ +COPY desktop/labwc/install-toolchain /tmp/labwc/install-toolchain +RUN test "$(uname -m)" = armv7l && \ + bash /tmp/desktop/install-toolchain && bash /tmp/labwc/install-toolchain && \ + pacman-download -Syu --noconfirm --needed arch-install-scripts qt6-base qt6-declarative qt6-tools && \ + pacman -Su --noconfirm --needed arch-install-scripts qt6-base qt6-declarative qt6-tools && \ + pacman -Q > /usr/share/omarchy-builder-packages.lock && \ + pacman -Sc --noconfirm && rm -rf /tmp/desktop /tmp/labwc +LABEL org.orchard.target.architecture="armv7h" +WORKDIR /work diff --git a/system/armv7/archlinuxarm-build.asc b/system/armv7/archlinuxarm-build.asc new file mode 100644 index 0000000..c551dd1 --- /dev/null +++ b/system/armv7/archlinuxarm-build.asc @@ -0,0 +1,114 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: Hockeypuck 2.2 +Comment: Hostname: + +xsFNBFLbBPMBEADNB2XChJQplQwbAcl8wkhsPZOozGhxUYO+BVEF5vKjxcNzeR57 +cjj1veSw4aMmEv03MkBHi9Kyyk2wKUkFHuTx4DA5ZxnTt+2ScEezEFcmEoLsRYid +eQ35tYWaFjpjZDLbR4bp0EumCi8zvxwQhXl1y4mRZtBCX8z4otdgXk8dBUSJJsHg +JsmRobzNrBDGEr55nNbT88BxVcG39idEb/VOOqS24rogNJvkQUdRwmu8BGbSWI/I +aLB0Wc3cMEMYCf5TjEwS9HsSvUOmdWE6RWibpnaQp9/CC2PHjP1QnXby3b1tA4nN +m3IkP8HqwpeIrSL3hXSGKMn3r8D/Sil1kfU1U5nll0yk1hFoAfVK4AWEvcXHqy3B +wDYa1iyiiqGxmNRyiK68EZlc20uGjG/GsNJx+/tAqWTOW8sJJrp+YGT/07uH2iRP +ivjWAetgih6xlmbbkskKm4hQI+sozWFObQzPe9R1lNLfncGXxxOWkkvGlbdZVXYn +PSKf6dF+H+lZLjSvhRznTlz+jM/Ou/9bmzf0kvJt6fOI4aR8ZeseXEJfxpA0Bdbx +arjPUrPj9XVLu75bFXMzeRIEW3zLd9OiHAnxfLlm/WDwc0zYjJIyU0V/KVhYx7Kq +mNlD5zpg4gIh+n53OpSZPsLCWxwa/5y8w8HLOEpZRB/N6ocmzISSiqWUlwARAQAB +zTZBcmNoIExpbnV4IEFSTSBCdWlsZCBTeXN0ZW0gPGJ1aWxkZXJAYXJjaGxpbnV4 +YXJtLm9yZz7CwXcEEwEKACEFAlLbBPMCGwMFCwkIBwMFFQoJCAsFFgIDAQACHgEC +F4AACgkQdxk/FSvb5qaJbA//Qv/rT5+HFQIOzDLwMrX+A7v8/ojq/21Gz3Ty8+ER +yvZvtg0OaX5CYdj7rVNgspwSNUggYPTKyuC7ooEXo2SwLOBMGku9k6wydzoipXnn +QZCYqVPi+m+ajbRvg8Ae7TDAtrauSxjeovHpCovgaWVnBk7Fe62AnSe6nUGLYXgB +L7NOYTb9yH8TmKAs7vaULAV3WLtQckiVad+1RwRlEqNoveyepjGEt+1FOftRPezd +EH3NCZb0tZOGjPxj1OQWY/TE4gfppvj8lrSY1hEP6U3ogn4v699yxAgVV31Inttg +CYccJJPIKpXgEa/5Iktxqp7CiSuBxBqbjsRgjSkz2NB5fcLWk7daCxdWS3jKgGOQ +shbwt9lNAeOF0THqm3cIe3JAP76A/cO0fOh0vL0zh1wHlDqIPYG4JXuBz9qL9SoU +rueVxuo2oqy7iNqEp2nuPl4Qw4XG4XGF9W0rPPGS/iDcCKJNyTo5OPGUXSPuLBQR +Cq6JaKze9iLBdHHXNFSCadZiMDSiVXNnTWFtNaagDTEgUvDbLLgtqlxZnDcERRH5 +1hWMWRaKrgFp/OEa2MRRwP/XBg7hZiHjBEUSEVwYT2Zzmsy7T0uXXu9LDtu4qnHp ++jNJaYPoRIhnlQPo6lBovA4rgz7kOdMUPQZRMPCgj6AW8gndzi52/i2NMRw75BiM +NlDCwVwEEAEKAAYFAlLbBdEACgkQvHBOhrgjzSXl6g/9GaIAKRZnNfbQZj0qKLeF +4Kb8dQClfI1dkBVkUG/g1doF6byCPCuXq9Jo8E0Ny5tXxaeLBEYe+kF8PexYiDv9 +QLFgKTkkUF/qEtrJo0FgC+XVEckfmpPDRghxUlU1Xr3u9iZmiYwtUaEm+3T5BbRO +0gCEypVjmYPCz/E3djpxwfWbBqgQpUSTNFu9PRMLji5Qe38XZp1xZqY+VBjIejYa +vRKKW2Nqs1bnUGz/LMbMmkhP+8BJ4LIUISmy4vD+6AJ+3+JzUeMFkAJGtd0fplQi +6Lr5tDWQP1Qs0dQrNbv+xZ0dSqquf/w0+FP10fu7XkgSrY+WiPZjUHUVaP96f4ap +T4waCTzAapRLSWUf5wZ7usFIuh0EPYCcnKtg0+SUrA5DwXmqQGtneSVI3roCK5Fx +Ws4ljKu22grMauOvqWwnEIYmHJqEn/fjfI8d4gDAtNW3GBVG8LmI1QXWrgPqtC6D +0/k80SysLdgjQxZwnwncHQCFKVWJp7lG/t5P/et7Ror46FGh700DqIN+aEN85Bbc +pQxF0WUf4KUlHUyMPQ5oV4B8MPTpojU0YK/aRvOu8obn9enrlAO7Ruazm2fVzT16 +95Efqw8Ip9kGwPJwtGVSGqwOeztBAs50F6ys3NgNm3KzWzthzTZRzNGj+ec0UM+a +VXro0jO5ZuPrtmbGas7uxJ/CwVwEEAEKAAYFAlLbC2AACgkQtVxTFdzZ7hr5yQ/9 +HifNPAt3WMeJe07qA85QKvbUMD649qzKXmletjL5hguJeAl6/ZB/fmtjo/eNl7qI +d1x+rcoypq5EVqYOSmaC/z0iZ9yihMF7+HljkN/ItUQx3bUUWoF4sljGFiLdUwEQ +NzzlbrXoeAe2q9DtOpfyEDt/6S/Yla24kJzP1KvFc1O2LeltpbsfJipmhKR8W2wz +kpkSYgLyNQAEfig8Ignldo8pFKoXb7J0+kNV/XjifUjNZGiywgWQCFkMFPWav61X +guYtSEvOuZfnCk8YSOKN2R2JUDg+PAPx3t8ojAyLMGjTAPyLaeYxV1w/mmulRQS1 +Cy1Bksg9Oh+inf9K9JYhSTuk6vInBUV+hvKm5Fi9vyZNw88A4GSgCzm4ng1Ac5YO +JqBikKfKw7YzuUAagWeJs6jdIXuzb8gKCsOf0q4G/lOC6cvLCDVDOaQbbnwL7kHU +Sw86wJzm/F60OXaq4/2DS0UqWscITC7s0oguAD2IBMw5NgvTE594/HDuKweXXOEv +hmQ/tiIYvRFlo68LO6KpftGSc6ek+mcdnlILDrZXNCnuffDVGq0ZTWvgbYSzhHbn +VCTI16hoqDZPbkOML0P5OL8VUlXg3Xb+5ZEBYiuFao0Mu2MbAWTgu26kvisrUKP0 +UhvaJCMA3w64VM64is/YFPjzKdD8B5/dVY/gcaDJIB7CwVwEEAECAAYFAlLbDeIA +CgkQv37vepxrV2Xnfw/+IU5lt43MAr1QDMx2DCJYk0SbPxqWvFDAY781BmoWgRxj +H23CuxJPfmwPkDnQd9erwVTAY3IIaFJ3ez9qnfc77MVVHYamwHV2fg0iIWw2uwte +6FW9dT/DV5WgTqDkKWvSUf21fR0LM1Ic3yi0DT6QN2lkyXa/n+Ful0GoqYUprXkm +rdJzuWjAaGFgBBTLByOOqu81i6cvoS2skEkmhKsAmC896Tw1mUf9hMnu/vw6EePo +4VpQZ2tmerPnoiZeHT2pMze2LGJxcLJeIGN/9de0txlt13wsHo9acv1XL46f98Ox +bkcgdG2kH8xWonCd5E/pxN3uBDtG8wduN3tHxHGOZ8J43U02xSyuQzvrPROO/3iY +/qGnnfbjJMO8Zere6mZHpo4LT23A3G2JkEsGtzQYiMBUz3QMLHK7fJF7/eY9x32b +OqfF3HHtg+NTyYa4TnXpVoknZq2i082KJ59YAC5WWWqiTOa7tb1+7BPB7SB3p+XF +LZYQWNw1swvN0j9sXTAOQLYO9y8pCLgSXRZy+9KFenwgfIVPysuP02PUoV7NXR3G +Pbln52xm80JeVAdU6BHL1ZETLxMbLh19iQEWdgw9yAX6xlmyk2ZSdkJwbEhibshf +nEMd+YrKqWk44KOQhFL1rxUSwu3MmpalxGctqxpbNbrqfGHqaG6JdGxXT8bn+k7C +wXMEEgEIAB0WIQTzKivN1T6CfIjM9v2rQxEDkBf7MgUCWcjJsQAKCRCrQxEDkBf7 +MvBREACYBmonKuSz11o6GC2sqxFSIEFLEGSMncAVdGlg5xwykxIJInurfr3M/5C/ +zD3sLcYUxk1dTOi5/jPKEgZM+TyZcyaLoXFcI14NSudzsyiDDwZu/zLQvZSr9qxk ++Mc/8t/nHMVL6BsVLWJRhJST2+ud1hAmt5USH07uQJyNVoFQI5zmId5V5sOditQg +w3to4jP/mq72KgZ+qvRJxo1SAUbJxXeZQFQak3441XGFUhWqge+NMprrnEgClybq +d8w35Vv3ywLQoIbkCv+yw1L68W/DbQKK+J7F3hMrT0Ssx75lmfnzXY3CFfZSriyu +iGeweKKKoxybWwqM+fsCSIoXBnTuCtK4S/zlrImJzLjYkhLh3+X+bj2pWHWZk/sA +zAhLml1gmq3Nqhb9fJVbU6FumUD/FOOIHwpZpWBJPP56EuwkkQ0Lnj8Nx4KKqiFY +yQtfSSkYW6jcw3lHLJ4vvxiPeh2t4S6ph0srI+t3qrVFJPRA2+YUmKxIn6I9mpKC +/3Nu3ha+azKdOVTjS1kBqeSW/2MSfLv8fwSJ9SNa1q+EKBlNNrfy6VB90lvgfxgg +XvXPjtyNVP95jM0L693EL9Q51b9iZehsYxYUdWaH+rp746XSi3iQn0nOVNiuYNhw +b+AYKzEiLeNsod7iyYETHSE8/OjU5Bvsw6KPSsjIU8K1nuMf9sJeBBAWCAAGBQJa +XnfhAAoJEBu4nAYCNnRJeLwBAP/jwJfNwh+Baw4p6YPcJQd3joYr21Ki1B2cx+pG +BHxvAP46+AUbQBlYHFYAsBBrVd5zb7kFrDhuQBxcCtCOOeb6B8LBXAQQAQgABgUC +XvLlVwAKCRCPhvLBs6vwdp3rEACBuVWH8fKWMBdFV6xMkEjbSZe5KAtIOsEApuUA +dAJ1xvLFKH9vEUBai6VAdppvWEK+Uy91XKBWpRfmh+uxzrf0IpDPFa4VjFQEz0pL +LGgaTu98bX3HkPe/uzgRrpXVevcDwokUQLcHrleDbHlald8ahBDcjILmJksP3XuH +rqlhU14NNKYshH4N0W/ol4Q7vYIw8Ke8rxRKQZ/FrmzqqxIc7Pc0+5aSMPD9leD8 +Y/Bsz+izJzAKEcgykfFt5L7g4vxU0+dwv2ZgurLhsh31e3y65YaxVYP++dNTkRUZ +V8AFPEQCISI5jP+ibWZ/831BJUCUiuj1w4mTHMG7ip7aVZQ1Buj3L+rkG5BRQjka +xjMwjraDlYBZX3kDIazimOL5iLRMvs3FEPOOR/1UIwpTNDYB8/HjqNkemhKxPKgS +4Nqe3lJZfWKCKVhw2bJJB9dtsCh4cI5mU8pImwzECepqMO+0Kho6cTTt2gJOHuUy +1ogFooPdp/fQMaZ+ai7J0XEsd4X0ddACjlxd84D9B4Vm/77YSYxhaVNkZEjUY35q +BNAZ5W35Hdw/tkTG/0NIBVT3xUeGZbuuTFKxsMzoMaEmVHLW0+JCKvI5pJLWeBd4 +Z+skgnjZxk9pHk7rhIb1YWoG2yFx2z4t957U6D0JXYKgOzaGg6IMm2hwHABkQuzJ +evJjasJ1BBAWCgAdFiEEPuNlJQ9ZMPOiz5izsXTGDdHAWR0FAmWN4VsACgkQsXTG +DdHAWR1zRwD9HAwvVuq86xE/7TVnePZVjoIXiaW0ycoLhe6tBFxpupEBAIwKSPyG +WDZDpxPpPYFpDrLUDS3UYRxP9Si/KVSUbrkKzsFNBFLbBPMBEADTUJPHGRWBGq51 +otAzjXBaReq7+jFdyNbK7SUDYhVpZpZ1FvcFoAnXJQAGTqJYVNI6nDYlkCqpnk/z +yBMZOvS9W0qUdbeU4rBy4XEp5PGo1BJ4B4hjMsMCEMCW/JAkYMrVMMUOZewNpIno +drxbuX3575MJsYT8f71ih2DL7Wz3ygvyVDwArHi2nEz4Q0QoPx1SG79ySHBOnh7Z +x5ZYk2MD2dcIiqlTTOdXeDewDdivM1DDnVgQ6Z5zF/Pce5wjIBNBeKA+qav4bPVf +3E0cDC7XoXnCxx8WrGOXrrvGDkIzY4jH5x1cHXrpQAjznwMhyBZjH+OLOMG26+5m +XRlyH+ly3juiMEwsCCRmtiZe+rPyfuXVzwgR9zNVcPRWJo1T8Ut7VXjJMlynFjk1 +iDWNg95suv3RCOjZqynXNyFI5PyoKZp3lTCa4vYLe7VMv9FB5TTW68kY8kC/UZ5a +gFIhqnlSDUIIaV2Wr6tGSMm6kiygpNtLaAfJqok1zu0OIBHgJKEI82XBal7Jlg1B +OmzK46pZ7mvB6E2irWl5q8cimX6YPLvbN9CRAygNpp7nVbux395kt6VUMx/KDG0n +RwgL4nU7NieKoxGVcjJW9Q8mfbmVjdktSOL+oEbPRnLUzu5bCjIE8ayMD4oWofsE +UxU+IGAdrWkhrt2xXpW4zN/bihRMPwARAQABwsFfBBgBCgAJBQJS2wTzAhsMAAoJ +EHcZPxUr2+amV/4P/3qaq7bn7EvmlHA7Wu+u0jH/j+ygJ2YrUQpMW1L/fY8JPhKW +JQXI8GJqcShyGX6+2LUnpnLSgvAwtzOy5Dmsn51ACvhiXcEBWlu82N2Oou/QY/Xz +r3JhslJ30K85w2jdSLNbq44dgTKAhyppvmi41OfHT2tetuBqc/ppNrdXnAGEdT+v +MBjgXAcU6uZ9hgiW5pJncFQ5LQcGpzgydexxealcQfg/Lsafktu4XErzGSvdTPc0 ++ntvFnBEdJGI4ZTc2+oYfdVbBWDM27RlfLLvLF7/Ed5nikQg6VPtyd9gejj+ATuM +6I4KYhYEkXuJJEiMcuf2ashrnC42ekNtgjTzTLqvsVY4Hy71OPVVTBvXaWu9tejM +b1eAQMLldRsQvsSyeUiY3RBBPYRxjrrYAJ1xKgGq1v+hKTZtKRd4HMpA2az09/zU +ICE+u3MzejXugIz205SZWuody8SiBJBGgMv28mmS6D7keNgX9JNNCJn8U+G+Wmu1 +P9cBWfUgk4bm+NbYvkV0i7jLkQJuE5oLe2fhjkQwiYgOIZ62FWG61VnXEIOFAyPo ++0xFocz7t6wKP5JXiRFd7iL4rOjzn1bQOjtvGVQ18YMBM3rKKnJt46VKGt7CJLy1 +kQ5cfpuLRal2/40R49JQ9ok73CAAowDA538esvPgNJAYmgJnGNNVP7qWF3h7 +=lA9r +-----END PGP PUBLIC KEY BLOCK----- diff --git a/system/armv7/bootstrap.json b/system/armv7/bootstrap.json new file mode 100644 index 0000000..757a58d --- /dev/null +++ b/system/armv7/bootstrap.json @@ -0,0 +1,11 @@ +{ + "schema": 1, + "architecture": "armv7h", + "url": "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz", + "signature_url": "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz.sig", + "bytes": 770429322, + "sha256": "ebe0fa119dea45e3af0aa61785676bdbf417bc5f754d529b87255dbf0ca4e3f0", + "signature_sha256": "0d4bb680d381c8053c378fad27cedb67446b39b7bff1c87257ebf6743db0fd94", + "signer_source": "https://archlinuxarm.org/about/package-signing", + "signer": "68B3537F39A313B3E574D06777193F152BDBE6A6" +} diff --git a/system/build-armv7-builder b/system/build-armv7-builder new file mode 100755 index 0000000..c9bcdda --- /dev/null +++ b/system/build-armv7-builder @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Prepare an ARMv7 compiler from a hash-pinned and signature-checked Arch root. + +The selected Podman connection must already execute ARM32 binaries. This +command does not install an emulator or change the host's default connection. +""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +from urllib.request import urlopen + + +PROJECT = Path(__file__).resolve().parents[1] +INPUTS = PROJECT / 'system/armv7' + + +def sha(path): + result = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def verify(archive, signature, pin): + if not archive.is_file() or not signature.is_file(): + raise ValueError('Bootstrap and signature must be regular files') + if archive.stat().st_size != pin['bytes'] or sha(archive) != pin['sha256']: + raise ValueError('ARMv7 bootstrap differs from its pinned bytes') + if sha(signature) != pin['signature_sha256']: + raise ValueError('ARMv7 bootstrap signature differs from its pin') + # Short paths keep GnuPG's local socket within the macOS path limit. + with tempfile.TemporaryDirectory(prefix='oa-armv7-gpg-') as home: + command = ['gpg', '--homedir', home, '--batch', '--no-auto-key-retrieve'] + subprocess.run([*command, '--import', str(INPUTS / 'archlinuxarm-build.asc')], + check=True, capture_output=True, text=True, timeout=30) + checked = subprocess.run([*command, '--status-fd', '1', '--verify', str(signature), str(archive)], + check=True, capture_output=True, text=True, timeout=60) + expected = '[GNUPG:] VALIDSIG ' + pin['signer'] + ' ' + if not any(line.startswith(expected) for line in checked.stdout.splitlines()): + raise ValueError('ARMv7 bootstrap signature has the wrong signer') + return pin['sha256'] + + +def fetch(url, path, maximum): + if path.exists(): + return path + temporary = tempfile.NamedTemporaryFile(prefix=path.name + '.', suffix='.part', dir=path.parent, delete=False) + partial = Path(temporary.name) + count = 0 + try: + with temporary as output, urlopen(url, timeout=60) as source: + while block := source.read(1024 * 1024): + count += len(block) + if count > maximum: + raise ValueError('Bootstrap download exceeds its pinned size bound') + output.write(block) + if path.exists(): + partial.unlink() + else: + partial.replace(path) + except BaseException: + partial.unlink(missing_ok=True) + raise + return path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + parser.add_argument('--bootstrap', type=Path, help='existing official archive; otherwise use the pinned download') + parser.add_argument('--signature', type=Path, help='signature for --bootstrap (default: ARCHIVE.sig)') + parser.add_argument('--cache', type=Path, default=PROJECT / 'system/.work/armv7-bootstrap') + parser.add_argument('--connection', help='Podman connection for this build') + parser.add_argument('--output-image', default='localhost/omarchy-a-arch-builder:armv7h') + parser.add_argument('--receipt', type=Path, required=True) + parser.add_argument('--verify-only', action='store_true') + args = parser.parse_args() + if args.receipt.exists(): + parser.error('Choose a new compiler receipt path') + pin = json.loads((INPUTS / 'bootstrap.json').read_text()) + args.cache.mkdir(parents=True, exist_ok=True) + archive = args.bootstrap or fetch(pin['url'], args.cache / 'bootstrap.tar.gz', pin['bytes']) + signature = args.signature or (Path(str(args.bootstrap) + '.sig') if args.bootstrap else + fetch(pin['signature_url'], args.cache / 'bootstrap.tar.gz.sig', 16384)) + verify(archive, signature, pin) + result = {'schema': 1, 'architecture': 'armv7h', 'bootstrap': pin, 'signature_valid': True} + if not args.verify_only: + podman = [shutil.which('podman') or 'podman'] + if args.connection: + podman += ['--connection', args.connection] + base = 'localhost/omarchy-a-armv7-base:' + pin['sha256'][:16] + exists = subprocess.run([*podman, 'image', 'exists', base], check=False, timeout=30).returncode == 0 + if not exists: + subprocess.run([*podman, 'import', '--arch', 'arm', '--variant', 'v7', + '--change', 'LABEL org.orchard.bootstrap.sha256=' + pin['sha256'], + str(archive.resolve()), base], check=True, timeout=600) + inspection = json.loads(subprocess.check_output([*podman, 'image', 'inspect', base], text=True, timeout=30))[0] + labels = inspection.get('Labels') or inspection.get('Config', {}).get('Labels', {}) + if labels.get('org.orchard.bootstrap.sha256') != pin['sha256']: + raise ValueError('Existing compiler base has another bootstrap pin') + subprocess.run([*podman, 'build', '--platform', 'linux/arm/v7', '--build-arg', 'BASE_IMAGE=' + base, + '-f', str(INPUTS / 'Containerfile'), '-t', args.output_image, str(PROJECT)], + check=True, timeout=7200) + result['image'] = args.output_image + result['image_id'] = subprocess.check_output([*podman, 'image', 'inspect', '--format', '{{.Id}}', + args.output_image], text=True, timeout=30).strip() + result['packages_lock'] = subprocess.check_output([*podman, 'run', '--rm', '--platform', 'linux/arm/v7', + args.output_image, 'cat', + '/usr/share/omarchy-builder-packages.lock'], text=True, timeout=120) + args.receipt.parent.mkdir(parents=True, exist_ok=True) + with args.receipt.open('x') as output: + json.dump(result, output, indent=2) + output.write('\n') + print('Verified ARMv7 compiler inputs:', args.receipt) + + +if __name__ == '__main__': + main() diff --git a/tests/test_armv7_bootstrap.py b/tests/test_armv7_bootstrap.py new file mode 100644 index 0000000..7795d77 --- /dev/null +++ b/tests/test_armv7_bootstrap.py @@ -0,0 +1,69 @@ +"""Compiler bootstrap verification binds its bytes and signer before import.""" +import hashlib +import importlib.util +import io +from pathlib import Path +import tempfile +import types +import unittest +from unittest.mock import patch + + +PROJECT = Path(__file__).resolve().parents[1] + + +def producer(): + from importlib.machinery import SourceFileLoader + loader = SourceFileLoader('armv7_bootstrap', str(PROJECT / 'system/build-armv7-builder')) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class BootstrapVerification(unittest.TestCase): + def fixtures(self, root): + archive, signature = root / 'root.tar.gz', root / 'root.tar.gz.sig' + archive.write_bytes(b'archive bytes') + signature.write_bytes(b'signature bytes') + pin = {'bytes': archive.stat().st_size, + 'sha256': hashlib.sha256(archive.read_bytes()).hexdigest(), + 'signature_sha256': hashlib.sha256(signature.read_bytes()).hexdigest(), + 'signer': 'A' * 40} + return archive, signature, pin + + def test_wrong_archive_or_signature_stops_before_gpg(self): + module = producer() + with tempfile.TemporaryDirectory() as directory: + archive, signature, pin = self.fixtures(Path(directory)) + for target in (archive, signature): + original = target.read_bytes() + target.write_bytes(b'changed') + with self.subTest(target=target.name), patch.object(module.subprocess, 'run') as run, \ + self.assertRaisesRegex(ValueError, 'pinned|pin'): + module.verify(archive, signature, pin) + run.assert_not_called() + target.write_bytes(original) + + def test_good_signature_from_another_key_is_refused(self): + module = producer() + with tempfile.TemporaryDirectory() as directory: + archive, signature, pin = self.fixtures(Path(directory)) + with patch.object(module.subprocess, 'run', return_value=types.SimpleNamespace( + stdout='[GNUPG:] VALIDSIG ' + 'B' * 40 + ' date\n')): + with self.assertRaisesRegex(ValueError, 'wrong signer'): + module.verify(archive, signature, pin) + + def test_download_bound_removes_partial_file(self): + module = producer() + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / 'archive' + with patch.object(module, 'urlopen', return_value=io.BytesIO(bytes(100))), \ + self.assertRaisesRegex(ValueError, 'size bound'): + module.fetch('http://example.invalid/archive', target, 99) + self.assertFalse(target.exists()) + self.assertFalse(target.with_suffix('.part').exists()) + + +if __name__ == '__main__': + unittest.main() From f134d9c8c9dd8ba587adaf95dd9456779c21501d Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:10:14 -0600 Subject: [PATCH 03/14] build: use supported ARMv7 linker flags Retain Arch hardening defaults while omitting RELR packing, which the ARM32 linker rejects. Compile and execute a check with the resulting makepkg flags when preparing the image. Assisted-by: LLM --- system/armv7/Containerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/system/armv7/Containerfile b/system/armv7/Containerfile index 4f0b6de..5d50def 100644 --- a/system/armv7/Containerfile +++ b/system/armv7/Containerfile @@ -8,5 +8,9 @@ RUN test "$(uname -m)" = armv7l && \ pacman -Su --noconfirm --needed arch-install-scripts qt6-base qt6-declarative qt6-tools && \ pacman -Q > /usr/share/omarchy-builder-packages.lock && \ pacman -Sc --noconfirm && rm -rf /tmp/desktop /tmp/labwc +# The shared makepkg defaults enable RELR, which GNU ld for ARM32 rejects. +# Keep the remaining Arch hardening flags and check them with this compiler. +RUN sed -i 's/ -Wl,-z,pack-relative-relocs//g' /etc/makepkg.conf && \ + bash -euc 'source /etc/makepkg.conf; printf "int main(void) { return 0; }\n" | cc ${CFLAGS} ${LDFLAGS} -x c - -o /tmp/compiler-check; /tmp/compiler-check; rm /tmp/compiler-check' LABEL org.orchard.target.architecture="armv7h" WORKDIR /work From 3c036bd6e570d39dc5e9a48fc405c2385d8b4730 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:41:07 -0600 Subject: [PATCH 04/14] build: forward selected LwVM tools to Orchard producers Require the selected platform input to supply a native static mapper for an LwVM root before compiling the desktop. Pass that helper and the selected rescue tools to installed initrd and RAM recovery assembly. Assisted-by: LLM --- BUILD.md | 12 ++++- release/build.py | 62 +++++++++++++++++++++++++- tests/test_architecture.py | 40 +++++++++++++++-- tests/test_release_architecture.py | 71 ++++++++++++++++++++++++++++++ tools/build.py | 24 ++++++---- 5 files changed, 194 insertions(+), 15 deletions(-) diff --git a/BUILD.md b/BUILD.md index 9058c47..9157cde 100644 --- a/BUILD.md +++ b/BUILD.md @@ -55,7 +55,7 @@ Retain each component's receipt. Do not change hardware activation or timing as ## Assemble The following example uses distro-owned components collected under `out/components/`. -A release covers every platform: one root image, and per platform a recovery +A release serves one userspace architecture: one root image, and per platform a recovery (`recovery-PLATFORM.bin`) and orchard's platform layer (`platform-PLATFORM.tar.gz`: kernel, every board's DTB, modules), which the installer writes into the root. The kernels come from the same orchard builder and cache as `orchard build` in @@ -91,13 +91,21 @@ To use a staged kernel bundle instead (as CI does with orchard's published component bundles), pass `--kernel-dir PLATFORM=DIRECTORY` per platform; the others still come from `--inputs`, and without `--inputs` the given bundles are the release's platforms. `--platform PLATFORM` builds only that -platform, for development; a release covers every platform. Each recovery carries +platform, for development; the default serves the AArch64 platforms. Each recovery carries the DTB of every board of its platform; m1n1 boots with the one matching the device. The cache respects `XDG_CACHE_HOME` and `ORCHARD_CACHE_DIR`, just like RAM builds. Choose a new version and output directory for each build. Root builds first; recoveries embed its exact stream receipt, and `release.toml` and the index are written last. The installed root carries no Orchard device runtime: it boots directly from the boot entry orchard's installer writes. +For the ARMv7 compiler and selected-board build, see +[ARMv7 desktop components](desktop/armv7/README.md). The producer checks +Orchard's inputs before compiling the desktop or assembling the root. A selected +input record with `root_format = "lwvm"` must supply the native static helper +at `tools.lwvm`; it is passed to both the installed initrd and RAM recovery. +An offline build can provide it through `tools/build.py --platform-lwvm +PLATFORM=FILE`. The selected Dropbear pair supplies early SSH recovery. + ## Validate without a device ```sh diff --git a/release/build.py b/release/build.py index a263b6f..d8f4635 100644 --- a/release/build.py +++ b/release/build.py @@ -11,6 +11,7 @@ import json from pathlib import Path import shutil +import struct import subprocess import sys import tomllib @@ -18,7 +19,7 @@ from .public_root import build as build_root, sha from release import snapshot from release.manifests import write as write_manifests -from release.architecture import validate, platform_arch, orchard_arch +from release.architecture import validate, platform_arch, orchard_arch, check_elf TOOLS = ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw') @@ -33,6 +34,45 @@ def orchard_files(args): return tomllib.loads((args.output / 'platforms.toml').read_text()) +def check_static_elf(path, architecture, label='helper'): + """Check a native helper before assembling a root that depends on it.""" + path = Path(path) + if path.stat().st_size > 32 * 1024 * 1024: + raise ValueError(f'{label}: static helper exceeds its inspection bound') + data = path.read_bytes() + check_elf(data, architecture, label) + arm = data[4] == 1 + offset = struct.unpack_from(' len(data): + raise ValueError(f'{label}: invalid ELF program headers') + loadable = False + for index in range(count): + start = offset + index * size + kind = struct.unpack_from(' len(data): + raise ValueError(f'{label}: invalid ELF dynamic segment') + for position in range(dynamic, dynamic + length, stride): + tag = struct.unpack_from(' Date: Fri, 6 Nov 2026 12:47:04 -0600 Subject: [PATCH 05/14] build: reject a mismatched board input record Keep explicit device selection consistent with the selected input boards before choosing their loader and storage tools. Assisted-by: LLM --- release/build.py | 3 +++ tests/test_release_architecture.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/release/build.py b/release/build.py index d8f4635..461b2f1 100644 --- a/release/build.py +++ b/release/build.py @@ -108,6 +108,9 @@ def platform_inputs(args, name, document=None): architecture = getattr(args, 'architecture', 'aarch64') row = (document or {}).get('platforms', {}).get(name, {}) boards = row.get('boards', {}) + requested_device = getattr(args, 'device', None) + if requested_device is not None and boards and requested_device not in boards: + raise ValueError(f'{name}: input boards differ from the selected device {requested_device}') resolved.device = next(iter(boards)) if len(boards) == 1 else getattr(args, 'device', None) if architecture == 'armv7h' and not resolved.device: raise ValueError(f'{name}: Cascadia requires one selected board in the input record or --device') diff --git a/tests/test_release_architecture.py b/tests/test_release_architecture.py index 91b3935..154410b 100644 --- a/tests/test_release_architecture.py +++ b/tests/test_release_architecture.py @@ -75,6 +75,9 @@ def test_armv7_components_come_from_its_platform_record(self): self.assertEqual(selected.kernel_image, 'zImage') self.assertEqual(selected.loader, paths['loader']) self.assertEqual(selected.busybox, paths['busybox']) + wrong_board = argparse.Namespace(**vars(args), device='j1') + with self.assertRaisesRegex(ValueError, 'input boards differ'): + build.platform_inputs(wrong_board, 'apple-armv7', inputs) row['loader']['kind'] = 'm1n1' with self.assertRaisesRegex(ValueError, 'loader kind'): build.platform_inputs(args, 'apple-armv7', inputs) From f4234b99c50723b637a86b5f45cbd9e565e688bc Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:08:18 -0600 Subject: [PATCH 06/14] build: recognize keymap BPF data in ARMv7 roots Check the known keymap data directory for framed ELF64 BPF relocatable objects before enforcing the native root ABI. Preserve rejection of foreign native objects and malformed or misplaced bytecode during assembly and audit. Assisted-by: LLM --- release/audit.py | 6 +++--- release/public_root.py | 25 ++++++++++++++++++++++++- tests/test_release_architecture.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/release/audit.py b/release/audit.py index 13a5c00..e51fb5e 100644 --- a/release/audit.py +++ b/release/audit.py @@ -12,9 +12,9 @@ from release.manifests import read_release from .common import PROJECT -from .public_root import sha +from .public_root import sha, check_root_elf from .recovery_audit import audit_runtime -from release.architecture import validate, check_elf +from release.architecture import validate # m1n1's payload framing (orchard docs/ram-development.md, "Assembling by hand"). INITRAMFS_MAGIC = b'm1n1_initramfs' @@ -263,7 +263,7 @@ def audit(manifest, orchard=None): elif member.isfile(): data = archive.extractfile(member).read() if data.startswith(b'\x7fELF'): - check_elf(data, architecture, name, executable=False) + check_root_elf(data, architecture, name) hashes[name] = hashlib.sha256(data).hexdigest() if name == 'arch-ipad-ui-manifest.json': desktop = json.loads(data) diff --git a/release/public_root.py b/release/public_root.py index deb9bbe..fc2b248 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -6,6 +6,7 @@ import json import os from pathlib import Path, PurePosixPath +import struct import tarfile import tempfile @@ -30,6 +31,28 @@ def safe_name(name): return str(path) +def check_root_elf(data, architecture, name): + """Keymap BPF objects are bytecode data; native objects must match the root.""" + path = PurePosixPath(name) + keymap = path.parent.as_posix() == 'usr/lib/udev/rc_keymaps/protocols' and path.suffix == '.o' + if (keymap and len(data) >= 64 and data[:7] == b'\x7fELF\x02\x01\x01' and + struct.unpack_from('= sections or + offset < 64 or offset + section_size * sections > len(data)): + raise ValueError('Invalid BPF data object section table: ' + name) + for index in range(sections): + start = offset + index * section_size + kind = struct.unpack_from(' len(data): # SHT_NOBITS has no file data. + raise ValueError('Truncated BPF data object section: ' + name) + return 'bpf' + return check_elf(data, architecture, name, executable=False) + + def desktop_replacements(): prefix = 'home/tablet/.local/share/omarchy-ipad/' replacements = {prefix + name: (PROJECT / 'desktop/shell' / name).read_bytes() for name in ( @@ -224,7 +247,7 @@ def add(member, data=None): raise ValueError('Base must link lib to usr/lib') data = source.extractfile(member).read() if member.isfile() else None if data is not None and data.startswith(b'\x7fELF'): - check_elf(data, architecture, member.name, executable=False) + check_root_elf(data, architecture, member.name) if architecture == 'armv7h' and member.name == 'usr/lib/systemd/systemd': check_elf(data, architecture, member.name) if member.name in ('etc/group', 'etc/gshadow'): diff --git a/tests/test_release_architecture.py b/tests/test_release_architecture.py index 154410b..3d2ef47 100644 --- a/tests/test_release_architecture.py +++ b/tests/test_release_architecture.py @@ -38,6 +38,17 @@ def static_elf(architecture): return bytes(data) +def bpf_elf(): + data = bytearray(200) + data[:7] = b'\x7fELF\x02\x01\x01' + struct.pack_into(' Date: Fri, 6 Nov 2026 19:29:07 -0600 Subject: [PATCH 07/14] build: preserve architecture contracts and pin release inputs Keep the ARM64 platform index and block-root producer while sending ARMv7 through its board-selected layer and recovery producers. Check the native CLI contract before resolving expensive inputs, and forward the static mapper and rescue tools. Bind source snapshots and component keys to the build receipt. Measure consumed files before assembly and verify them afterward, preserve archive-tree provenance, and keep base and final release stamps distinct. Filter published layer fields through the schema contract. Keep generic installer packaging scoped to its supported ARM64 path while native asset qualification remains pending. Assisted-by: LLM --- BUILD.md | 75 ++++--- docs/releasing.md | 12 +- release/audit.py | 91 ++++++-- release/build.py | 139 ++++++++++-- release/manifests.py | 15 +- release/provenance.py | 109 ++++++++++ release/public_root.py | 12 +- release/recovery_audit.py | 2 +- release/smoke_desktop.py | 3 +- tests/test_architecture.py | 99 ++++++++- tests/test_cached_kernel.py | 8 +- tests/test_release_architecture.py | 16 +- tests/test_release_contracts.py | 335 +++++++++++++++++++++++++++++ tests/test_release_provenance.py | 72 +++++++ tools/build.py | 56 +++-- 15 files changed, 944 insertions(+), 100 deletions(-) create mode 100644 release/provenance.py create mode 100644 tests/test_release_contracts.py create mode 100644 tests/test_release_provenance.py diff --git a/BUILD.md b/BUILD.md index 9157cde..ed6c7bd 100644 --- a/BUILD.md +++ b/BUILD.md @@ -17,9 +17,10 @@ orchard build [--version VERSION] builds a release into `out/releases/VERSION` (default `dev-DATE-TIME`) through `tools/build.py`, the entry `orchard.toml` names: each component below is kept in `out/components/NAME/KEY/`, keyed by its files, and rebuilt only when they -change; the release is assembled and audited every time. `orchard run` then boots -its recovery on the attached device without installing, and `orchard install` -installs it. The rest of this page is what that entry runs, step by step. +change; the release is assembled and audited every time. For the public +AArch64 flow, `orchard run` starts the selected kernel on the attached device +and `orchard install` installs the release. The rest of this page describes +the producer steps, including the gated ARMv7 assets. ## Local setup @@ -47,17 +48,23 @@ Python 3.11. - `desktop/build-sources` and `desktop/build`: desktop applications and payload. - `system/build-rootfs`: signed Arch Linux ARM base and package receipt; see [system/README.md](system/README.md). - orchard supplies the kernel, DTBs and modules of every platform, its platform layer, and the static BusyBox/Dropbear recipes; see [orchard's build instructions](https://github.com/OrchardLinux/orchard/blob/main/BUILD.md). Local assembly builds or reuses each platform's kernel through orchard's shared cache. Build the static tools separately; supply explicit kernel bundles only when overriding that default. -- `python boot/build-artwork --output DIR`: the Omarchy mark as data for orchard's m1n1: `splash` (480 px, painted by m1n1 for an installed boot entry) and `logo.bin` (m1n1's custom logo payload, 256 and 128 px, shown by the recovery). No m1n1 is built here; releases boot orchard's, `quiet/m1n1.bin` from the component bundle. -- Pongo is not built here. Releases ship orchard's one Pongo, `quiet/pongo.bin` from the orchard component bundle (black screen; the logo appears with m1n1). +- `python boot/build-artwork --output DIR`: the Omarchy mark for AArch64's m1n1 flow: `splash` (480 px, painted for an installed boot entry) and `logo.bin` (256 and 128 px). AArch64 releases use Orchard's `quiet/m1n1.bin` from the component bundle. +- AArch64 releases ship Orchard's `quiet/pongo.bin` from the component bundle. ARMv7 uses its Cascadia recovery assets. Retain each component's receipt. Do not change hardware activation or timing as part of presentation changes. ## Assemble The following example uses distro-owned components collected under `out/components/`. -A release serves one userspace architecture: one root image, and per platform a recovery -(`recovery-PLATFORM.bin`) and orchard's platform layer (`platform-PLATFORM.tar.gz`: -kernel, every board's DTB, modules), which the installer writes into the root. +A release serves one userspace architecture. AArch64 uses Orchard's platform +index and block-image producer: `root.blocks.zst`, `root.map.json`, the +selected platform layers, m1n1, Pongo and install tools. Orchard supplies these +files through `orchard.image.platforms_index`. + +ARMv7 takes a selected-board input record and calls Orchard's native platform +and recovery producers. Its assets contain `rootfs.tar.zst`, a Cascadia recovery +and a platform layer with the LwVM initrd. The public platform index serves the +AArch64 installer contract. The kernels come from the same orchard builder and cache as `orchard build` in orchard's examples; matching inputs reuse the compiled bundles. Boot artwork, userspace and assembly remain owned by this repository; the m1n1 is orchard's. @@ -71,32 +78,31 @@ python -m release.build \ --inputs out/inputs.json \ --base out/components/base/rootfs.tar.gz \ --base-receipt out/components/base/rootfs.tar.gz.json \ - --loader out/components/quiet/m1n1.bin \ --splash out/components/artwork/splash \ --logo out/components/artwork/logo.bin \ - --pongo out/components/quiet/pongo.bin \ --busybox out/components/boot/busybox \ --dropbear out/components/boot/dropbear \ --dropbearkey out/components/boot/dropbearkey \ - --wpa-supplicant out/components/boot/wpa_supplicant \ - --wpa-cli out/components/boot/wpa_cli \ - --iw out/components/boot/iw \ --cursor-tools out/components/desktop/cursor-tools \ --bluetooth-tools out/components/desktop/bluetooth-tools \ --labwc-tools out/components/desktop/labwc-tools \ --output out/releases/0.1.0-orchard.3 ``` -To use a staged kernel bundle instead (as CI does with orchard's published -component bundles), pass `--kernel-dir PLATFORM=DIRECTORY` per platform; the -others still come from `--inputs`, and without `--inputs` the given bundles are -the release's platforms. `--platform PLATFORM` builds only that -platform, for development; the default serves the AArch64 platforms. Each recovery carries -the DTB of every board of its platform; m1n1 boots with the one matching the -device. The cache respects `XDG_CACHE_HOME` and `ORCHARD_CACHE_DIR`, just like RAM -builds. - -Choose a new version and output directory for each build. Root builds first; recoveries embed its exact stream receipt, and `release.toml` and the index are written last. The installed root carries no Orchard device runtime: it boots directly from the boot entry orchard's installer writes. +To use a staged kernel bundle, pass `--kernel-dir PLATFORM=DIRECTORY` to +`orchard.inputs`. Select a subset there with `--platform PLATFORM`; the index +consumes that document's exact selection. `release.build --orchard-release TAG` +takes finished AArch64 files from a published Orchard build and accepts +`--platform PLATFORM` to select its layers. The cache respects `XDG_CACHE_HOME` +and `ORCHARD_CACHE_DIR`, just like RAM builds. + +Choose a new version and output directory for each build. Root builds first; +the matching asset producers follow. `release.toml` and the index are written +last. A native recovery embeds the root's exact stream receipt. +The cached base retains `base-` in `etc/ipadlinux/release`; +`etc/orchard/release.json` identifies the final release. Assembly verifies the +base stamp against its receipt. `build-receipt.json` records both stamps, +Omarchy's source identity, measured component manifests and the final root hash. For the ARMv7 compiler and selected-board build, see [ARMv7 desktop components](desktop/armv7/README.md). The producer checks @@ -105,6 +111,22 @@ input record with `root_format = "lwvm"` must supply the native static helper at `tools.lwvm`; it is passed to both the installed initrd and RAM recovery. An offline build can provide it through `tools/build.py --platform-lwvm PLATFORM=FILE`. The selected Dropbear pair supplies early SSH recovery. +The snapshot must expose the installed-root and recovery mapper options before +component compilation starts. ARMv7 requires a selected `--inputs` document +with one board, its Cascadia loader and native static tools. + +The native installed-root path requires Orchard's +[ARMv7 producers](https://github.com/OrchardLinux/orchard/pull/82), +[Cascadia boot transport](https://github.com/OrchardLinux/orchard/pull/84), +[LwVM platform layer](https://github.com/OrchardLinux/orchard/pull/92) and +[native recovery installer](https://github.com/OrchardLinux/orchard/pull/94). +Its input record includes `architecture = "arm"`, `image = "zImage"` and +`root_format = "lwvm"`, with the static mapper at `tools.lwvm`. + +The generic web installer implements `checkm8-pongo`; the ARMv7 build emits +the native root, layer and recovery assets. P105's public installation gate +remains closed. Public install and launch support for its boot method remains +open. Physical qualification must bind these assets to an exact source revision. ## Validate without a device @@ -114,7 +136,12 @@ python -m release.audit out/releases/0.1.0-orchard.3/release.toml PYTHONPATH=$ORCHARD_CHECKOUT/src python -m orchard.manifests --verify-local out/releases/0.1.0-orchard.3/index.toml ``` -The auditor verifies, per platform, the recovery's contents, endpoints and loader framing, orchard's layer against its receipt and SHA256SUMS, and that recovery and layer carry the same modules; for the root, desktop/privilege policy, that it holds no file of any layer, and the absence of private session/firmware data. +The auditor checks AArch64's block image, map and installer assets. ARMv7 adds +checks of the recovery's endpoints, Cascadia framing and matching modules. +Both paths verify platform layers against their receipts and SHA256SUMS, +desktop integrity, package privileges and private-data exclusions. +LwVM checks require matching native mapper and SSH helper bytes in the +installed initrd and RAM recovery. Headless finished-artifact checks use Podman without network or device passthrough: diff --git a/docs/releasing.md b/docs/releasing.md index b6a87c2..2f251ec 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,12 @@ # Publishing Omarchy A -Build and validate a coherent local release using [BUILD.md](../BUILD.md). Its rootfs and recovery must be produced together; do not combine artifacts from different builds. +Build and validate a coherent local release using [BUILD.md](../BUILD.md). Its +root and boot assets must come from that build. The public workflow below +serves AArch64's block-image installer. + +ARMv7 produces local root archive, platform layer and Cascadia recovery assets. +Its public installation and launch method remains gated in Orchard. Keep those +assets with their audit receipts until that method is integrated and qualified. ## GitHub builds @@ -38,13 +44,13 @@ https://github.com/OrchardLinux/omarchy-a/releases/latest/download/index.toml Only that entry point follows latest. Its manifest and image references point to specific release tags, preventing cross-release mixing. -Every release carries its installer: `install.sh` and `orchard-install.pyz` (orchard's core with this release's index, device data and launch loaders), made by `orchard.installer` from the orchard release CI pins and rewritten by `release.publish` for the release's URLs. The README's command runs the latest release's `install.sh`; the repository's `install.sh` only forwards to it, for older instructions. Publish the pinned [orchard release](https://github.com/OrchardLinux/orchard/blob/main/docs/releases.md) first: its tag provides the components and the loaders. Change the README's install command only once a release with `install.sh` is published. +Every public AArch64 release carries its installer: `install.sh` and `orchard-install.pyz` (orchard's core with this release's index, device data and launch loaders), made by `orchard.installer` from the orchard release CI pins and rewritten by `release.publish` for the release's URLs. The README's command runs the latest release's `install.sh`; the repository's `install.sh` only forwards to it, for older instructions. Publish the pinned [orchard release](https://github.com/OrchardLinux/orchard/blob/main/docs/releases.md) first: its tag provides the components and the loaders. Change the README's install command only once a release with `install.sh` is published. Before announcing a release: - Resolve its hosted index with `orchard resolve https://github.com/OrchardLinux/omarchy-a/releases/latest/download/index.toml` from a clean host environment. - Test installation and `orchard launch` after a cold restart on supported hardware; do not infer device qualification from unit tests or container checks. -- Confirm the published root and recovery come from the audited build and retain their build receipts, source pins, licenses and corresponding source/build inputs. +- Confirm the published root and boot assets come from the audited build and retain their build receipts, source pins, licenses and corresponding source/build inputs. - Include the experimental status and the README's automatic-login/passwordless-sudo warning in release notes. ## Updates diff --git a/release/audit.py b/release/audit.py index e51fb5e..12f90d7 100644 --- a/release/audit.py +++ b/release/audit.py @@ -1,10 +1,13 @@ """Offline audit of finished assets: namespace safety, integrity and boot contracts.""" import argparse +import gzip import hashlib +import io import json from pathlib import Path, PurePosixPath import subprocess import struct +import stat import tarfile import zlib @@ -13,8 +16,9 @@ from .common import PROJECT from .public_root import sha, check_root_elf -from .recovery_audit import audit_runtime -from release.architecture import validate +from .recovery_audit import audit_runtime, read_newc +from .build import selected_root_format +from release.architecture import validate, check_elf # m1n1's payload framing (orchard docs/ram-development.md, "Assembling by hand"). INITRAMFS_MAGIC = b'm1n1_initramfs' @@ -175,7 +179,7 @@ def asset(manifest, item, role): def audit_layer(path, platform, row, private): """orchard's platform layer: root-owned files under usr/ that say which platform and kernel they are.""" - hashes, expanded = {}, 0 + hashes, expanded, initrd = {}, 0, None with tarfile.open(path, 'r:gz') as archive: for member in archive: name = member.name @@ -191,6 +195,8 @@ def audit_layer(path, platform, row, private): description = json.loads(data) if name == 'usr/lib/orchard/platform.SHA256SUMS': sums = data.decode() + if name.endswith('/initrd') and name.startswith('usr/lib/modules/'): + initrd = data if (len(hashes), expanded) != (row['members'], row['expanded_bytes']): raise ValueError(f'The {platform} layer differs from its receipt') if (description['platform'], description['kernel_release']) != (platform, row['kernel_release']): @@ -202,7 +208,57 @@ def audit_layer(path, platform, row, private): expected, name = line.split(' ', 1) if hashes.get(name.lstrip('/')) != expected: raise ValueError(f'The {platform} layer differs from its SHA256SUMS: {name}') - return {'hashes': hashes, 'boards': description['boards'], 'kernel_release': release} + return {'hashes': hashes, 'boards': description['boards'], 'kernel_release': release, 'initrd': initrd} + + +def audit_lwvm_helpers(initrd, runtime, architecture): + """The installed initrd and RAM recovery carry the same native mapper and SSH tools.""" + paths = {'bin/orchard-lwvm': 'usr/sbin/orchard-lwvm', 'bin/dropbear': 'usr/sbin/dropbear', + 'bin/dropbearkey': 'usr/bin/dropbearkey', 'bin/busybox': 'bin/busybox'} + found = {} + if initrd is None: + raise ValueError('LwVM platform has no installed initrd') + with gzip.GzipFile(fileobj=io.BytesIO(initrd)) as stream: + for name, mode, data in read_newc(stream): + if name not in paths: + continue + if not stat.S_ISREG(mode) or mode & 0o111 != 0o111: + raise ValueError('Installed LwVM helper is not executable: ' + name) + check_elf(data, architecture, name) + target = paths[name] + digest = hashlib.sha256(data).hexdigest() + if (runtime['hashes'].get(target) != digest or + not stat.S_ISREG(runtime['modes'].get(target, 0)) or + runtime['modes'].get(target, 0) & 0o111 != 0o111): + raise ValueError('Installed and recovery LwVM helpers differ: ' + name) + found[name] = digest + if found.keys() != paths.keys(): + raise ValueError('LwVM initrd lacks its mapper or early SSH helpers') + return found + + +def audit_platform(manifest, name, files, private, *, architecture, release, root_format='gpt'): + layer = audit_layer(asset(manifest, files['layer'], name + ' layer'), name, files['layer'], private) + if sorted(layer['boards']) != sorted(files['boards']): + raise ValueError(f'The manifest and the {name} layer list different boards') + result = {'boards': sorted(layer['boards']), 'kernel_release': layer['kernel_release'], + 'layer_files': len(layer['hashes'])} + if architecture == 'armv7h': + image = asset(manifest, files['boot'], name + ' recovery') + recovery = audit_recovery(image, architecture) + if recovery['dtbs'] != len(set(layer['boards'].values())): + raise ValueError(f'The {name} recovery does not start every board of its platform') + runtime = audit_runtime(image, layer['kernel_release'], release, private) + base = f'usr/lib/modules/{layer["kernel_release"]}/' + for path, digest in layer['hashes'].items(): + if (path.startswith(base) and path not in (base + 'vmlinuz', base + 'initrd') and + not path.startswith(base + 'dtb/') and runtime['hashes'].get(path) != digest): + raise ValueError(f'The {name} recovery and layer have different modules: {path}') + if root_format == 'lwvm': + recovery['lwvm_helpers'] = audit_lwvm_helpers(layer['initrd'], runtime, architecture) + recovery['runtime_members_verified'] = runtime['members'] + result['recovery'] = recovery + return result, layer['hashes'] def audit(manifest, orchard=None): @@ -223,26 +279,15 @@ def audit(manifest, orchard=None): root_path = asset(manifest, record['root'], 'root') platforms, layers = {}, {} for name, files in sorted(record['platforms'].items()): - layer = audit_layer(asset(manifest, files['layer'], name + ' layer'), name, files['layer'], private) - if sorted(layer['boards']) != sorted(files['boards']): - raise ValueError(f'The manifest and the {name} layer list different boards') + root_format = 'gpt' if architecture == 'armv7h': - image = asset(manifest, files['boot'], name + ' recovery') - recovery = audit_recovery(image, architecture) - if recovery['dtbs'] != len(set(layer['boards'].values())): - raise ValueError(f'The {name} recovery does not start every board of its platform') - runtime = audit_runtime(image, layer['kernel_release'], record['release'], private) - # The recovery runs the kernel the installed entry boots: the same modules. - base = f'usr/lib/modules/{layer["kernel_release"]}/' - for path, digest in layer['hashes'].items(): - if (path.startswith(base) and path not in (base + 'vmlinuz', base + 'initrd') and - not path.startswith(base + 'dtb/') and - runtime['hashes'].get(path) != digest): - raise ValueError(f'The {name} recovery and layer have different modules: {path}') - recovery['runtime_members_verified'] = runtime['members'] - platforms[name] = {'boards': sorted(layer['boards']), 'kernel_release': layer['kernel_release'], - 'layer_files': len(layer['hashes']), 'recovery': recovery} - layers[name] = layer['hashes'] + formats = {selected_root_format(snapshot.directory(orchard), name, board) for board in files['boards']} + if len(formats) != 1: + raise ValueError('Native platform boards use different root formats') + root_format = formats.pop() + platforms[name], layers[name] = audit_platform( + manifest, name, files, private, architecture=architecture, + release=record['release'], root_format=root_format) hashes, links, modes, owners, groups, sizes = {}, {}, {}, {}, {}, {} names, ancestors, directories = set(), set(), set() desktop = privileges = cursor = bluetooth = labwc = None diff --git a/release/build.py b/release/build.py index 461b2f1..514a0d7 100644 --- a/release/build.py +++ b/release/build.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 """Build an architecture-specific root and consume Orchard's matching installation contract. -The root image holds no kernel. orchard's platform layer (kernel, DTBs, -modules) goes next to it, and the installer writes it into the root -(orchard docs/boot-contract.md). The recovery of a platform boots every board -of it, from the same kernel bundle as its layer. +Orchard's platform layer supplies the kernel, DTBs and modules. AArch64 uses +its platform index and block-image producer. ARMv7 builds a selected board's +native layer and recovery from the same kernel bundle. """ import argparse import copy import json from pathlib import Path +import re import shutil import struct import subprocess @@ -20,6 +20,8 @@ from release import snapshot from release.manifests import write as write_manifests from release.architecture import validate, platform_arch, orchard_arch, check_elf +from .common import PROJECT +from .provenance import component_receipt, file_receipt, source_identity, orchard_identity, producer_identity, input_revision TOOLS = ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw') @@ -28,7 +30,7 @@ def orchard_files(args): """Take ARM64 platform layers and block-image install tools from Orchard's index.""" source = ('--release', args.orchard_release) if args.orchard_release else ('--inputs', args.inputs) - selection = [part for name in (args.platform or ()) for part in ('--platform', name)] + selection = [part for name in (args.platform or ()) for part in ('--platform', name)] if args.orchard_release else [] snapshot.command(args.orchard, 'orchard.image.platforms_index', args.output, *source, *selection, stdout=subprocess.DEVNULL) return tomllib.loads((args.output / 'platforms.toml').read_text()) @@ -93,6 +95,9 @@ def kernels(args, document=None): if unknown or not selected: raise ValueError(f'Unknown platform {", ".join(unknown) or "(none)"}; known: {", ".join(known) or "none"}') architecture = validate(getattr(args, 'architecture', 'aarch64')) + declared = (document or {}).get('architecture') + if declared is not None and declared != orchard_arch(architecture): + raise ValueError('Input document architecture differs from the root architecture') for name in selected: if platform_arch(name, getattr(args, 'orchard', None)) != architecture: raise ValueError(f'{name}: platform architecture differs from {architecture} root') @@ -102,6 +107,39 @@ def kernels(args, document=None): return {name: explicit.get(name) or Path(offered[name]['kernel']) for name in selected} +def require_native_contract(orchard, components=None, *, lwvm_required=False): + """Check the selected snapshot's public commands before compiling a native root.""" + components = components or {} + options = { + 'orchard.image.platform': {'--root-architecture', '--device'}, + 'orchard.image.recovery': {'--platform', '--device'}, + } + if lwvm_required or any(getattr(component, 'lwvm', None) is not None for component in components.values()): + options['orchard.image.platform'].update(('--lwvm', '--rescue-tools')) + options['orchard.image.recovery'].add('--lwvm') + for module, required in options.items(): + try: + result = snapshot.command(orchard, module, '--help', capture_output=True, text=True, timeout=30) + except (OSError, subprocess.SubprocessError) as error: + raise ValueError(f'Orchard native producer is unavailable: {module}') from error + offered = set(re.findall(r'--[a-z][a-z0-9-]*', result.stdout)) + missing = sorted(required - offered) + if missing: + raise ValueError(f'{module} lacks the native root contract ({", ".join(missing)}); ' + 'use Orchard with the installed-root and recovery producers') + + +def selected_root_format(orchard, platform, device): + """The snapshot's root format for a selected board.""" + described = tomllib.loads((Path(orchard) / 'platforms' / (platform + '.toml')).read_text()).get('root', {}) + result = described.get('format', 'gpt') + if described.get('devices') is not None and device not in described['devices']: + result = 'gpt' + if result not in ('gpt', 'lwvm'): + raise ValueError(f'{platform}: unsupported root format {result}') + return result + + def platform_inputs(args, name, document=None): """Resolve the loader/tools of NAME without borrowing another ISA's shared inputs.""" resolved = copy.copy(args) @@ -112,7 +150,7 @@ def platform_inputs(args, name, document=None): if requested_device is not None and boards and requested_device not in boards: raise ValueError(f'{name}: input boards differ from the selected device {requested_device}') resolved.device = next(iter(boards)) if len(boards) == 1 else getattr(args, 'device', None) - if architecture == 'armv7h' and not resolved.device: + if architecture == 'armv7h' and (len(boards) != 1 or not resolved.device): raise ValueError(f'{name}: Cascadia requires one selected board in the input record or --device') loader = row.get('loader', {}) expected_loader = 'cascadia' if architecture == 'armv7h' else 'm1n1' @@ -134,9 +172,15 @@ def platform_inputs(args, name, document=None): path = getattr(resolved, field) if path.is_symlink() or not path.is_file(): raise ValueError(f'{name}: missing regular component {field}: {path}') + if architecture == 'armv7h': + for tool in TOOLS: + check_static_elf(getattr(resolved, tool), architecture, name + ' ' + tool) root_format = row.get('root_format', 'gpt') if root_format not in ('gpt', 'lwvm'): raise ValueError(f'{name}: unsupported root format {root_format}') + orchard = getattr(args, 'orchard', None) + if orchard is not None and root_format != selected_root_format(orchard, name, resolved.device): + raise ValueError(f'{name}: input root format differs from the selected board') helper = row.get('tools', {}).get('lwvm', getattr(args, 'lwvm', None)) resolved.lwvm = Path(helper) if helper is not None else None if root_format == 'lwvm' and resolved.lwvm is None: @@ -189,6 +233,21 @@ def recovery(args, name, bundle, boards, root_receipt): return output +def consumed_files(args, components, bundles): + paths = {name: path for name, path in vars(args).items() + if isinstance(path, Path) and (path.is_file() or path.is_symlink())} + for name, component in components.items(): + for role in ('loader', *TOOLS, 'lwvm'): + path = getattr(component, role, None) + if path is not None: + paths[f'platforms.{name}.{role}'] = path + for name, bundle in bundles.items(): + for path in sorted(bundle.rglob('*')): + if path.is_file(): + paths[f'kernel.{name}.{path.relative_to(bundle).as_posix()}'] = path + return {name: file_receipt(path) for name, path in sorted(paths.items())} + + def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) for name in ('base', 'base-receipt', 'splash', 'cursor-tools', 'bluetooth-tools', 'labwc-tools', 'output'): @@ -197,6 +256,8 @@ def main(): parser.add_argument('--' + name, type=Path) parser.add_argument('--architecture', choices=('aarch64', 'armv7h'), default='aarch64') parser.add_argument('--device', help='board profile when explicit ARMv7 components are used without an inputs record') + parser.add_argument('--base-release', help='expected release stamp of the cached base component') + parser.add_argument('--component-keys', type=Path, help='cache keys supplied by tools/build.py') parser.add_argument('--orchard', type=Path, help='the orchard snapshot (default: ORCHARD_CHECKOUT)') origin = parser.add_mutually_exclusive_group() origin.add_argument('--orchard-release', metavar='TAG', help='take ARM64 platform files from this Orchard build') @@ -219,32 +280,65 @@ def main(): for name in ('cursor_tools', 'bluetooth_tools', 'labwc_tools'): if not getattr(args, name).is_dir(): parser.error(f'Missing tools directory: {getattr(args, name)}') + initial_arguments = consumed_files(args, {}, {}) try: args.orchard = snapshot.directory(args.orchard) document = json.loads(args.inputs.read_text()) if args.inputs else None + orchard_source = orchard_identity(args.orchard) + producer = producer_identity(args.orchard, PROJECT) + revision = input_revision(orchard_source, document.get('orchard', {}) if document is not None else None) if args.architecture == 'aarch64': if args.inputs is None and args.orchard_release is None: raise ValueError('Name --inputs or --orchard-release for the ARM64 platform index') if args.kernel_dir: raise ValueError('Put explicit ARM64 kernels in the selected --inputs document') if document is not None: - kernels(args, document) # refuse a different ISA before assembling the root + selected = kernels(args, document) + if set(selected) != set(document['platforms']): + raise ValueError('Create a selected --inputs document before building an ARM64 platform subset') components, bundles = {}, {} else: if args.orchard_release is not None: - raise ValueError('ARMv7 requires selected inputs or explicit components; the platform index serves ARM64') + raise ValueError('ARMv7 requires selected --inputs; the platform index serves ARM64') + if document is None: + raise ValueError('ARMv7 requires a selected --inputs document with one board') bundles = kernels(args, document) components = {name: platform_inputs(args, name, document) for name in bundles} + require_native_contract(args.orchard, components) except (ValueError, OSError) as error: parser.error(str(error)) - revision = (document or {}).get('orchard') or snapshot.revision(args.orchard) - if not revision: - parser.error(f'{args.orchard} is no git checkout and no --inputs names its revision') + omarchy = source_identity(PROJECT) + component_records = {name: component_receipt(getattr(args, name + '_tools')) + for name in ('cursor', 'bluetooth', 'labwc')} + base_record = json.loads(args.base_receipt.read_text()) + if not isinstance(base_record.get('release'), str) or not base_record['release']: + parser.error('The base receipt must name its release stamp') + if args.base_release is not None and base_record['release'] != args.base_release: + parser.error('Base release differs from the expected cache stamp; rebuild the base component') + cache_keys = {} + if args.component_keys is not None: + keys = json.loads(args.component_keys.read_text()) + cache_keys = keys.get('components', {}) + if (keys.get('schema') != 1 or keys.get('architecture') != args.architecture or + set(cache_keys) != {'desktop', 'cursor', 'bluetooth', 'labwc', 'base', 'artwork'} or + any(not isinstance(value, str) or not re.fullmatch('[0-9a-f]{64}', value) + for value in cache_keys.values())): + parser.error('Invalid component cache-key receipt') + if base_record['release'] != 'base-' + cache_keys['desktop'][:16]: + parser.error('Base release stamp differs from its desktop cache key') + if keys.get('producer') != producer: + parser.error('Cached components name another Orchard build environment') + initial_inputs = consumed_files(args, components, bundles) + if any(initial_inputs.get(name) != receipt for name, receipt in initial_arguments.items()): + raise ValueError('Consumed input changed before release assembly') root_tools = next(iter(components.values()), args) root = build_root(args.base, args.base_receipt, args.output, args.release, args.cursor_tools, args.bluetooth_tools, args.labwc_tools, tools={tool: getattr(root_tools, tool) for tool in ('dropbear', 'dropbearkey', 'busybox')}, splash=args.splash, architecture=args.architecture) + public_root = dict(root) + if public_root.get('stamps') != {'base': base_record['release'], 'orchard': args.release}: + raise ValueError('Final root stamps differ from the base and release contract') if args.architecture == 'aarch64': offered = orchard_files(args) snapshot.command(args.orchard, 'orchard.image.root_image', args.output / 'rootfs.tar.zst', '--output', args.output) @@ -264,13 +358,28 @@ def main(): offered = platforms shutil.rmtree(args.output / '.work', ignore_errors=True) # recovery and layer staging published = write_manifests(args.output, args.release, root, offered, revision, architecture=args.architecture) + if source_identity(PROJECT) != omarchy: + raise ValueError('Omarchy source changed during release assembly') + if orchard_identity(args.orchard) != orchard_source: + raise ValueError('Orchard source changed during release assembly') + if consumed_files(args, components, bundles) != initial_inputs: + raise ValueError('Consumed input changed during release assembly') + for name, measured in component_records.items(): + if component_receipt(getattr(args, name + '_tools')) != measured: + raise ValueError('Component changed during release assembly: ' + name) + root_asset = args.output / 'rootfs.tar.zst' + if file_receipt(root_asset) != {key: public_root[key] for key in ('bytes', 'sha256')}: + raise ValueError('Final root archive differs from its receipt') # Assembly provenance is separate from the public installation contract. receipt = {'schema': 2, 'release': args.release, 'architecture': args.architecture, - 'orchard': revision, 'platforms': offered['platforms'] if args.architecture == 'aarch64' else offered, - 'inputs': {name: {'bytes': path.stat().st_size, 'sha256': sha(path)} - for name, path in vars(args).items() if isinstance(path, Path) and path.is_file()}} + 'orchard': revision, 'orchard_source': orchard_source, 'producer': producer, + 'omarchy': omarchy, 'components': component_records, 'cache_keys': cache_keys, + 'base': {'archive': file_receipt(args.base), 'receipt': file_receipt(args.base_receipt), + 'metadata': base_record}, 'public_root': public_root, 'root': root, + 'platforms': offered['platforms'] if args.architecture == 'aarch64' else offered, + 'inputs': initial_inputs} (args.output / 'build-receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') - print('Public release ready:', published) + print('Release assets ready:', published) print('Pinned index:', args.output / 'index.toml') diff --git a/release/manifests.py b/release/manifests.py index 3a80da1..e76dd46 100644 --- a/release/manifests.py +++ b/release/manifests.py @@ -4,7 +4,9 @@ from pathlib import Path import tomllib -from release.architecture import validate, platform_arch +from release.architecture import validate, platform_arch, orchard_arch + +LAYER_FIELDS = ('file', 'url', 'bytes', 'sha256', 'compression', 'expanded_bytes', 'members', 'kernel_release') def sha(path): @@ -26,8 +28,8 @@ def table(name, row): def write(output, version, root, offered, orchard, *, architecture='aarch64'): """release.toml and index.toml in OUTPUT. - PLATFORMS maps each platform to its boards, recovery (`boot`) and - orchard layer receipts; ORCHARD is the snapshot's revision ({'commit', 'modified'}). + OFFERED is Orchard's ARM64 platform index or the ARMv7 board's native + layer and recovery receipts. ORCHARD records the source revision. """ validate(architecture) platforms = offered['platforms'] if architecture == 'aarch64' else offered @@ -55,8 +57,10 @@ def write(output, version, root, offered, orchard, *, architecture='aarch64'): document += table('m1n1', offered['loaders']['m1n1']) document += table('install_tools', offered['install_tools']) for name, layer in sorted(platforms.items()): + if layer.get('architecture', 'arm64') != orchard_arch(architecture): + raise ValueError('Platform layer architecture differs from release') document += f'\n[platforms.{name}]\nboards = {json.dumps(layer["boards"])}\n' - document += table(f'platforms.{name}.layer', {key: value for key, value in layer.items() if key != 'boards'}) + document += table(f'platforms.{name}.layer', {key: layer[key] for key in LAYER_FIELDS if key in layer}) else: row = receipt(output / 'rootfs.tar.zst') row.update({key: root[key] for key in ('compression', 'expanded_bytes', 'members')}) @@ -64,7 +68,8 @@ def write(output, version, root, offered, orchard, *, architecture='aarch64'): for name, files in sorted(platforms.items()): document += f'\n[platforms.{name}]\nboards = {json.dumps(files["boards"])}\n' document += table(f'platforms.{name}.boot', files['boot']) - document += table(f'platforms.{name}.layer', files['layer']) + document += table(f'platforms.{name}.layer', {key: files['layer'][key] + for key in LAYER_FIELDS if key in files['layer']}) manifest = output / 'release.toml' with manifest.open('x') as stream: stream.write(document) diff --git a/release/provenance.py b/release/provenance.py new file mode 100644 index 0000000..df655ed --- /dev/null +++ b/release/provenance.py @@ -0,0 +1,109 @@ +"""Measure the source and finished components consumed by release assembly.""" +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import stat +import subprocess + +from . import snapshot +from .public_root import sha + + +def file_receipt(path): + path = Path(path) + if path.is_symlink() or not path.is_file(): + raise ValueError('Provenance requires a regular file: ' + str(path)) + return {'bytes': path.stat().st_size, 'sha256': sha(path)} + + +def component_receipt(directory): + directory = Path(directory) + if directory.is_symlink() or not directory.is_dir(): + raise ValueError('Provenance requires a component directory') + manifest = file_receipt(directory / 'manifest.json') + record = json.loads((directory / 'manifest.json').read_text()) + if record.get('schema') != 1 or not isinstance(record.get('files'), dict): + raise ValueError('Invalid component manifest') + files = {} + for name, expected in sorted(record['files'].items()): + relative = PurePosixPath(name) + if relative.is_absolute() or '..' in relative.parts or not relative.parts: + raise ValueError('Unsafe component member') + path = directory.joinpath(*relative.parts) + if directory.resolve() not in path.resolve().parents or any( + directory.joinpath(*relative.parts[:index]).is_symlink() + for index in range(1, len(relative.parts) + 1)): + raise ValueError('Linked or escaping component member') + measured = file_receipt(path) + if measured['sha256'] != expected: + raise ValueError('Component member differs from manifest: ' + name) + files[name] = measured + return {'manifest': manifest, 'files': files, + **{key: record[key] for key in ('architecture', 'source', 'compiler_image') if key in record}} + + +def source_identity(project): + project = Path(project).resolve() + try: + top = subprocess.run(['git', '-C', str(project), 'rev-parse', '--show-toplevel'], + capture_output=True, text=True, check=True) + checkout = Path(top.stdout.strip()).resolve() == project + except (OSError, subprocess.CalledProcessError): + checkout = False + revision = snapshot.revision(project) if checkout else None + try: + if not checkout: + raise FileNotFoundError('Unversioned source snapshot') + result = subprocess.run(['git', '-C', str(project), 'ls-files', '-z', '--cached', '--others', + '--exclude-standard'], capture_output=True, check=True, + env={**os.environ, 'GIT_OPTIONAL_LOCKS': '0'}) + names = {name.decode() for name in result.stdout.split(b'\0') if name} + if revision is not None: + revision['modified'] = bool(subprocess.run( + ['git', '-C', str(project), 'status', '--porcelain'], capture_output=True, check=True, + env={**os.environ, 'GIT_OPTIONAL_LOCKS': '0'}).stdout) + except (OSError, subprocess.CalledProcessError): + names = set() + for directory, dirs, files in os.walk(project): + dirs[:] = sorted(name for name in dirs if name not in ('.git', '.venv', 'out', '.work', '__pycache__')) + names.update((Path(directory) / name).relative_to(project).as_posix() for name in files) + entries = {} + for name in sorted(names): + path = project / name + if path.is_symlink(): + entries[name] = {'mode': stat.S_IMODE(path.lstat().st_mode), 'link': os.readlink(path)} + elif path.is_file(): + entries[name] = {'mode': stat.S_IMODE(path.stat().st_mode), **file_receipt(path)} + else: + entries[name] = {'missing': True} + digest = hashlib.sha256(json.dumps(entries, sort_keys=True).encode()).hexdigest() + return {'commit': (revision or {}).get('commit'), 'modified': (revision or {}).get('modified'), + 'tree_sha256': digest, 'files': len(entries)} + + +def orchard_identity(root): + identity = source_identity(root) + if identity['commit'] is None: + if identity['modified'] is not None or (Path(root) / '.git').exists(): + raise ValueError('Orchard Git snapshot revision is unavailable') + elif identity['modified'] is not False: + raise ValueError('Choose a clean Orchard snapshot for release production') + return identity + + +def input_revision(identity, declared=None): + expected = {key: identity[key] for key in ('commit', 'modified')} + if declared is None: + if expected['commit'] is None: + raise ValueError('Unversioned Orchard snapshots require matching null/null input revision metadata') + elif (not isinstance(declared, dict) or + any(key not in declared or declared[key] != value for key, value in expected.items())): + raise ValueError('Orchard inputs revision differs from the producer snapshot') + return expected + + +def producer_identity(orchard, project): + return {'orchard': orchard_identity(orchard), + 'buildenv': file_receipt(Path(orchard) / 'src/orchard/buildenv.py'), + 'wrapper': file_receipt(Path(project) / 'desktop/snapshot_buildenv.py')} diff --git a/release/public_root.py b/release/public_root.py index fc2b248..001c854 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -209,6 +209,7 @@ def build(base, base_receipt, outdir, release, cursor_tools, bluetooth_tools=Non replacements = desktop_replacements() outdir.parent.mkdir(parents=True, exist_ok=True) seen, directories, expanded, count = set(), set(), 0, 0 + base_stamp = None with tempfile.TemporaryDirectory(dir=outdir.parent, prefix='.root-') as temp: temporary = Path(temp) archive = temporary / 'rootfs.tar.zst' @@ -246,6 +247,12 @@ def add(member, data=None): if member.name == 'lib' and (not member.issym() or member.linkname != 'usr/lib'): raise ValueError('Base must link lib to usr/lib') data = source.extractfile(member).read() if member.isfile() else None + if member.name == 'etc/ipadlinux/release': + if data is None or len(data) > 256: + raise ValueError('Invalid base release stamp') + base_stamp = data.decode().rstrip('\n') + if record.get('release') is not None and data != (record['release'] + '\n').encode(): + raise ValueError('Base release stamp differs from its receipt') if data is not None and data.startswith(b'\x7fELF'): check_root_elf(data, architecture, member.name) if architecture == 'armv7h' and member.name == 'usr/lib/systemd/systemd': @@ -307,9 +314,12 @@ def add_parents(name): 'arch-ipad-ui-manifest.json'} if not required <= seen or not privileges.keys() <= seen or not replacements.keys() <= seen: raise ValueError('Incomplete public root or privileged package metadata') + if record.get('release') is not None and base_stamp is None: + raise ValueError('Base release stamp is missing') receipt = {'schema': 2, 'kind': 'rootfs', 'release': release, 'architecture': architecture, 'bytes': archive.stat().st_size, 'sha256': sha(archive), 'compression': 'zstd', - 'expanded_bytes': expanded, 'members': count, 'base_sha256': record['sha256']} + 'expanded_bytes': expanded, 'members': count, 'base_sha256': record['sha256'], + 'stamps': {'base': base_stamp, 'orchard': release}} (temporary / 'rootfs.json').write_text(json.dumps(receipt, indent=2) + '\n') os.replace(temporary, outdir) return receipt diff --git a/release/recovery_audit.py b/release/recovery_audit.py index 850a4dc..6c061c1 100644 --- a/release/recovery_audit.py +++ b/release/recovery_audit.py @@ -86,4 +86,4 @@ def audit_runtime(image, kernel_release, release, private): digest, name = line.split(' ', 1) if hashes.get(name.lstrip('/')) != digest: raise ValueError('Recovery public runtime differs: ' + name) - return {'members': len(names), 'hashes': hashes, 'public': public} + return {'members': len(names), 'hashes': hashes, 'modes': modes, 'public': public} diff --git a/release/smoke_desktop.py b/release/smoke_desktop.py index 9ce2b93..233e425 100644 --- a/release/smoke_desktop.py +++ b/release/smoke_desktop.py @@ -23,12 +23,13 @@ def main(): # Verify namespace safety and all published digests before using GNU tar. checked = audit(manifest) record = read_release(manifest) + root_archive = 'rootfs.tar.zst' if record['root'].get('format') == 'blocks' else record['root']['file'] args.output.mkdir(parents=True) output = args.output.resolve() recipe = Path(__file__).with_name('smoke-desktop-in-container').resolve() argv = ['podman', 'run', '--rm', '--network', 'none', '--userns', 'keep-id:uid=0,gid=0', '--cap-add', 'SYS_ADMIN', '--cap-add', 'SYS_PTRACE', '--security-opt', 'seccomp=unconfined', - '-e', 'OMA_ROOT_FILENAME=' + record['root']['file'], + '-e', 'OMA_ROOT_FILENAME=' + root_archive, '-v', str(manifest.parent) + ':/release:ro', '-v', str(recipe) + ':/validate.sh:ro', '-v', str(output) + ':/evidence:rw', args.image, 'sh', '/validate.sh'] if args.pointer_helper: diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 659ecbb..d78bb61 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -3,6 +3,7 @@ import json import os from pathlib import Path +import shutil import struct import sys import tempfile @@ -13,6 +14,8 @@ PROJECT = Path(__file__).resolve().parents[1] +PRODUCER = {'orchard': {'commit': 'a' * 40, 'modified': False, 'tree_sha256': 'b' * 64}, + 'buildenv': {'sha256': 'c' * 64}, 'wrapper': {'sha256': 'd' * 64}} def executable(architecture): @@ -26,6 +29,17 @@ def executable(architecture): return bytes(data) +def static_executable(architecture): + arm = architecture == 'armv7h' + data = bytearray(executable(architecture)) + offset, size = len(data), 32 if arm else 56 + data.extend(bytes(size)) + struct.pack_into(' Date: Fri, 6 Nov 2026 19:38:29 -0600 Subject: [PATCH 08/14] tests: restore the snapshot environment after preflight Keep the temporary Orchard checkout confined to the early-contract test so later publication cases resolve their real source snapshot. The complete host suite passes 186 cases with 22 artifact skips. Assisted-by: LLM --- tests/test_architecture.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_architecture.py b/tests/test_architecture.py index d78bb61..d959e03 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -215,7 +215,8 @@ def test_native_cli_preflight_precedes_cold_input_production(self): device.parent.mkdir(parents=True) device.write_text('[kernel]\nplatform = "apple-armv7"\n') argv = ['build', '--version', 'test', '--output', str(root / 'out'), '--orchard', str(root), '--device', 'p105'] - with patch.object(sys, 'argv', argv), patch.object(entry, 'orchard_command') as inputs, \ + with patch.dict(os.environ, dict(os.environ)), patch.object(sys, 'argv', argv), \ + patch.object(entry, 'orchard_command') as inputs, \ patch.object(entry, 'component') as component, \ patch.object(entry, 'require_native_contract', side_effect=ValueError('missing --lwvm')) as check, \ self.assertRaisesRegex(ValueError, 'missing --lwvm'): From a2aa3b1fb4c34fc562fed540d390502df982636f Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:40:52 -0600 Subject: [PATCH 09/14] desktop: set payload directory modes before root assembly A private build umask made newly staged system and home parents root-only. The base overlay preserved those modes over the installed packages, which blocked the tablet session. Set public directories to 0755 and the tablet home subtree to 0700 before packaging. Preserve file modes and leave symlink targets unchanged. Assisted-by: LLM --- desktop/stage.py | 6 ++++++ tests/test_greeter_staging.py | 20 +++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/desktop/stage.py b/desktop/stage.py index 9edc0ed..411db39 100644 --- a/desktop/stage.py +++ b/desktop/stage.py @@ -128,6 +128,12 @@ def main(): (target / "usr/local/libexec/arch-ipad-rotate-output").chmod(0o755) (target / "usr/local/libexec/arch-ipad-touch-output").chmod(0o755) (target / "usr/local/libexec/arch-ipad-login-session").chmod(0o755) + # The base overlay preserves directory modes. Set the target layout + # explicitly so a private build umask cannot block the tablet session. + home = target / "home/tablet" + for path in [target, *target.rglob("*")]: + if not path.is_symlink() and path.is_dir(): + path.chmod(0o700 if path.is_relative_to(home) else 0o755) # Record a content hash for each staged payload file. files = sorted(p for p in target.rglob("*") if p.is_file() and not p.is_symlink()) manifest = {str(p.relative_to(target)): hashlib.sha256(p.read_bytes()).hexdigest() for p in files} diff --git a/tests/test_greeter_staging.py b/tests/test_greeter_staging.py index d9f03fb..21ec36a 100644 --- a/tests/test_greeter_staging.py +++ b/tests/test_greeter_staging.py @@ -1,5 +1,6 @@ import hashlib import importlib.util +import os from pathlib import Path import sys import struct @@ -24,6 +25,10 @@ def test_trusted_ui_fallback_theme_and_tmpfiles_are_staged(self): (theme / 'backgrounds/first.webp').write_bytes(b'fallback') (upstream / 'applications').mkdir() (upstream / 'applications/foot.desktop').write_text('[Desktop Entry]\n') + outside = root / 'outside-theme' + outside.mkdir(mode=0o711) + outside.chmod(0o711) + (upstream / 'themes/external-theme').symlink_to(outside, target_is_directory=True) keyboard = root / 'keyboard' elf = bytearray(64) elf[:7] = b'\x7fELF\x02\x01\x01' @@ -37,7 +42,20 @@ def test_trusted_ui_fallback_theme_and_tmpfiles_are_staged(self): '--keyboard-sha256', hashlib.sha256(keyboard.read_bytes()).hexdigest(), '--omafox-xpi', str(xpi)] with patch.object(sys, 'argv', args), patch.object(stage, 'XPI_SHA', hashlib.sha256(xpi.read_bytes()).hexdigest()): - stage.main() + previous = os.umask(0o077) + try: + stage.main() + finally: + os.umask(previous) + for directory in [target, *target.rglob('*')]: + if not directory.is_symlink() and directory.is_dir(): + expected = 0o700 if directory.is_relative_to(target / 'home/tablet') else 0o755 + self.assertEqual(directory.stat().st_mode & 0o777, expected, directory) + self.assertEqual(outside.stat().st_mode & 0o777, 0o711) + self.assertTrue((target / 'home/tablet/.local/share/omarchy-ipad/themes/external-theme').is_symlink()) + self.assertEqual((target / 'etc/greetd/config.toml').stat().st_mode & 0o777, + (stage.HERE / 'greeter/greetd.toml').stat().st_mode & 0o777) + self.assertEqual((target / 'usr/local/libexec/arch-ipad-login-session').stat().st_mode & 0o777, 0o755) menu = target / 'home/tablet/.local/share/omarchy-ipad/default/omarchy/omarchy-menu.jsonc' self.assertIn('"system.shutdown": {"icon":"󰐥","label":"Shutdown","action":"systemctl poweroff"}', menu.read_text()) self.assertIn('"system.reboot": {"icon":"󰜉","label":"Reboot","action":"systemctl reboot"}', menu.read_text()) From c519963193aebae6664de12793604723810831b4 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:08:27 -0600 Subject: [PATCH 10/14] tests: follow the selected Cascadia producer layout Choose the shared codec from the current Orchard snapshot and keep coverage for its earlier module location. Explicitly skip the companion framing cases when the selected main snapshot has no Cascadia producer command. Codec import and execution errors still propagate. Assisted-by: LLM --- tests/test_release_architecture.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_release_architecture.py b/tests/test_release_architecture.py index 9a3b9fd..6bbfae4 100644 --- a/tests/test_release_architecture.py +++ b/tests/test_release_architecture.py @@ -13,7 +13,7 @@ import unittest from unittest.mock import patch -from release import audit, build, manifests, public_root +from release import audit, build, manifests, public_root, snapshot def elf(architecture): @@ -345,7 +345,13 @@ def produce(*argv, **kwargs): def cascadia_fixture(self, root): # Exercise the companion Orchard producer's real framing contract. - from orchard.image import cascadia + orchard = snapshot.directory() + if not (orchard / 'src/orchard/image/cascadia.py').is_file(): + self.skipTest('Orchard snapshot has no Cascadia producer command') + if (orchard / 'src/orchard/cascadia_payload.py').is_file(): + from orchard import cascadia_payload as cascadia + else: + from orchard.image import cascadia described = {'schema': 1, 'load_address': 0x80000000, 'kernel_offset': 0x8000, 'watchdog_address': 0x3c100000, 'l2_address': 0xb0000000, 'ram_start': 0x80000000, 'ram_end': 0xa0000000, From d81ee7887f48139312b95d1cbcfc4482a1f68d0f Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:40:23 -0600 Subject: [PATCH 11/14] build: drain the rootfs export stream through EOF A host tar consumer can exit at the archive end marker while the container still writes legal zero padding. With pipefail enabled, the producer's SIGPIPE then aborts an export whose files were already extracted. Use the portable --ignore-zeros option so extraction consumes the complete stream. Exercise the producer through a padded export and retain failure coverage for corrupted and truncated archives. Assisted-by: LLM --- system/build-rootfs | 3 +- tests/test_rootfs_export.py | 109 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/test_rootfs_export.py diff --git a/system/build-rootfs b/system/build-rootfs index 2c31e99..b20e6cb 100755 --- a/system/build-rootfs +++ b/system/build-rootfs @@ -70,10 +70,11 @@ else /repo/system/assemble-rootfs \ --root /work/root --output /work/rootfs.tar.gz \ --desktop /inputs/desktop --release "$release" --architecture "$architecture" + # Drain the export through EOF, including tar's trailing zero padding. "$podman" run --rm "${container_args[@]}" --network none -v "$volume:/work:ro" \ "$builder_image" \ tar -C /work -cf - rootfs.tar.gz rootfs.tar.gz.json packages.lock \ - | tar -C "$stage" -xf - + | tar -C "$stage" --ignore-zeros -xf - fi python3 - "$stage" "$architecture" <<'PY' import hashlib, json, pathlib, sys diff --git a/tests/test_rootfs_export.py b/tests/test_rootfs_export.py new file mode 100644 index 0000000..bbab961 --- /dev/null +++ b/tests/test_rootfs_export.py @@ -0,0 +1,109 @@ +"""The root producer must consume a complete container export stream.""" +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest + + +PROJECT = Path(__file__).resolve().parents[1] +PODMAN = r'''#!/usr/bin/env python3 +import json, os, signal, sys +args = sys.argv[1:] +with open(os.environ['EXPORT_CALLS'], 'a') as stream: + stream.write(json.dumps(args) + '\n') +if args == ['info'] or args[:2] == ['volume', 'rm']: + sys.exit(0) +if args[:2] == ['volume', 'create']: + print(args[2]) + sys.exit(0) +if args[0] == 'run' and '/repo/system/assemble-rootfs' in args: + sys.exit(0) +if args[0] == 'run' and 'tar' in args: + signal.signal(signal.SIGPIPE, signal.SIG_DFL) + with open(os.environ['EXPORT_ARCHIVE'], 'rb') as source: + while data := source.read(16384): + while data: + data = data[os.write(1, data):] + open(os.environ['EXPORT_DRAINED'], 'x').close() + sys.exit(0) +sys.exit('Unexpected fixture Podman command: ' + repr(args)) +''' + + +@unittest.skipUnless(shutil.which('bash') and shutil.which('tar') and shutil.which('python3'), + 'bash, tar and Python are required for the export fixture') +class RootfsExportTests(unittest.TestCase): + def run_export(self, change=None): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + project = root / 'project' + (project / 'system').mkdir(parents=True) + shutil.copy2(PROJECT / 'system/build-rootfs', project / 'system/build-rootfs') + (project / 'desktop').mkdir() + # This fixture exercises export and verification after desktop checking + # and native assembly have succeeded, without starting a container. + (project / 'desktop/build').write_text('#!/usr/bin/env python3\n') + (project / 'desktop/build').chmod(0o755) + desktop = root / 'desktop' + (desktop / 'payload').mkdir(parents=True) + (desktop / 'packages').mkdir() + binary = root / 'bin' + binary.mkdir() + (binary / 'podman').write_text(PODMAN) + (binary / 'podman').chmod(0o755) + contents = b'root-fixture\n' * 8192 + lock = b'fixture 1\n' + record = {'architecture': 'armv7h', 'release': 'export-fixture', + 'bytes': len(contents), 'sha256': hashlib.sha256(contents).hexdigest(), + 'packages_lock_sha256': hashlib.sha256(lock).hexdigest()} + files = {'rootfs.tar.gz': contents, 'rootfs.tar.gz.json': json.dumps(record).encode(), + 'packages.lock': lock} + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode='w', format=tarfile.GNU_FORMAT) as archive: + for name, data in files.items(): + member = tarfile.TarInfo(name) + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + # Legal padding larger than a pipe buffer exposes an extractor that + # exits at the tar end marker before its producer finishes writing. + data = buffer.getvalue().ljust(4 * 1024 * 1024, b'\0') + if change == 'corrupt': + data = data[:515] + bytes((data[515] ^ 1,)) + data[516:] + elif change == 'truncated': + data = data[:512 + len(contents) // 2] + stream = root / 'export.tar' + stream.write_bytes(data) + drained, calls, output = root / 'drained', root / 'calls.jsonl', root / 'output' + result = subprocess.run(['bash', str(project / 'system/build-rootfs'), + '--output', str(output), '--desktop', str(desktop), '--release', 'export-fixture', + '--architecture', 'armv7h', '--builder-image', 'export-fixture'], capture_output=True, + text=True, timeout=20, env={**os.environ, 'ORCHARD_BUILD_NATIVE': '0', + 'PATH': str(binary) + os.pathsep + os.environ['PATH'], + 'EXPORT_ARCHIVE': str(stream), 'EXPORT_DRAINED': str(drained), 'EXPORT_CALLS': str(calls)}) + self.assertTrue(any(row[:2] == ['volume', 'rm'] + for row in map(json.loads, calls.read_text().splitlines()))) + return result, output, drained, files + + def test_padded_export_is_drained_before_success(self): + result, output, drained, files = self.run_export() + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertTrue(drained.is_file(), 'The archive producer did not reach EOF') + for name, expected in files.items(): + self.assertEqual((output / name).read_bytes(), expected) + + def test_corrupted_export_is_refused(self): + result, output, _, _ = self.run_export('corrupt') + self.assertNotEqual(result.returncode, 0) + self.assertFalse(output.exists()) + + def test_truncated_export_is_refused(self): + result, output, _, _ = self.run_export('truncated') + self.assertNotEqual(result.returncode, 0) + self.assertFalse(output.exists()) From 1127766be06b5597a3af60463201e848b54fe160 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:32:15 +0800 Subject: [PATCH 12/14] ARMv7 builder: the bootstrap is one dated build, its copy first Arch Linux ARM publishes only a "latest" bootstrap, which it replaces, so a hash pin of it breaks at the next refresh. The pin names the 2026-08-05 build (still upstream's latest today), fetched from a copy in this repository's releases first, then from upstream while upstream still serves that build. Hash and signature checks are unchanged. --- release/provenance.py | 109 ------------------------------- release/recovery_audit.py | 89 ------------------------- system/armv7/bootstrap.json | 13 +++- system/build-armv7-builder | 25 ++++++- tests/test_armv7_bootstrap.py | 21 ++++++ tests/test_release_provenance.py | 72 -------------------- 6 files changed, 54 insertions(+), 275 deletions(-) delete mode 100644 release/provenance.py delete mode 100644 release/recovery_audit.py delete mode 100644 tests/test_release_provenance.py diff --git a/release/provenance.py b/release/provenance.py deleted file mode 100644 index df655ed..0000000 --- a/release/provenance.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Measure the source and finished components consumed by release assembly.""" -import hashlib -import json -import os -from pathlib import Path, PurePosixPath -import stat -import subprocess - -from . import snapshot -from .public_root import sha - - -def file_receipt(path): - path = Path(path) - if path.is_symlink() or not path.is_file(): - raise ValueError('Provenance requires a regular file: ' + str(path)) - return {'bytes': path.stat().st_size, 'sha256': sha(path)} - - -def component_receipt(directory): - directory = Path(directory) - if directory.is_symlink() or not directory.is_dir(): - raise ValueError('Provenance requires a component directory') - manifest = file_receipt(directory / 'manifest.json') - record = json.loads((directory / 'manifest.json').read_text()) - if record.get('schema') != 1 or not isinstance(record.get('files'), dict): - raise ValueError('Invalid component manifest') - files = {} - for name, expected in sorted(record['files'].items()): - relative = PurePosixPath(name) - if relative.is_absolute() or '..' in relative.parts or not relative.parts: - raise ValueError('Unsafe component member') - path = directory.joinpath(*relative.parts) - if directory.resolve() not in path.resolve().parents or any( - directory.joinpath(*relative.parts[:index]).is_symlink() - for index in range(1, len(relative.parts) + 1)): - raise ValueError('Linked or escaping component member') - measured = file_receipt(path) - if measured['sha256'] != expected: - raise ValueError('Component member differs from manifest: ' + name) - files[name] = measured - return {'manifest': manifest, 'files': files, - **{key: record[key] for key in ('architecture', 'source', 'compiler_image') if key in record}} - - -def source_identity(project): - project = Path(project).resolve() - try: - top = subprocess.run(['git', '-C', str(project), 'rev-parse', '--show-toplevel'], - capture_output=True, text=True, check=True) - checkout = Path(top.stdout.strip()).resolve() == project - except (OSError, subprocess.CalledProcessError): - checkout = False - revision = snapshot.revision(project) if checkout else None - try: - if not checkout: - raise FileNotFoundError('Unversioned source snapshot') - result = subprocess.run(['git', '-C', str(project), 'ls-files', '-z', '--cached', '--others', - '--exclude-standard'], capture_output=True, check=True, - env={**os.environ, 'GIT_OPTIONAL_LOCKS': '0'}) - names = {name.decode() for name in result.stdout.split(b'\0') if name} - if revision is not None: - revision['modified'] = bool(subprocess.run( - ['git', '-C', str(project), 'status', '--porcelain'], capture_output=True, check=True, - env={**os.environ, 'GIT_OPTIONAL_LOCKS': '0'}).stdout) - except (OSError, subprocess.CalledProcessError): - names = set() - for directory, dirs, files in os.walk(project): - dirs[:] = sorted(name for name in dirs if name not in ('.git', '.venv', 'out', '.work', '__pycache__')) - names.update((Path(directory) / name).relative_to(project).as_posix() for name in files) - entries = {} - for name in sorted(names): - path = project / name - if path.is_symlink(): - entries[name] = {'mode': stat.S_IMODE(path.lstat().st_mode), 'link': os.readlink(path)} - elif path.is_file(): - entries[name] = {'mode': stat.S_IMODE(path.stat().st_mode), **file_receipt(path)} - else: - entries[name] = {'missing': True} - digest = hashlib.sha256(json.dumps(entries, sort_keys=True).encode()).hexdigest() - return {'commit': (revision or {}).get('commit'), 'modified': (revision or {}).get('modified'), - 'tree_sha256': digest, 'files': len(entries)} - - -def orchard_identity(root): - identity = source_identity(root) - if identity['commit'] is None: - if identity['modified'] is not None or (Path(root) / '.git').exists(): - raise ValueError('Orchard Git snapshot revision is unavailable') - elif identity['modified'] is not False: - raise ValueError('Choose a clean Orchard snapshot for release production') - return identity - - -def input_revision(identity, declared=None): - expected = {key: identity[key] for key in ('commit', 'modified')} - if declared is None: - if expected['commit'] is None: - raise ValueError('Unversioned Orchard snapshots require matching null/null input revision metadata') - elif (not isinstance(declared, dict) or - any(key not in declared or declared[key] != value for key, value in expected.items())): - raise ValueError('Orchard inputs revision differs from the producer snapshot') - return expected - - -def producer_identity(orchard, project): - return {'orchard': orchard_identity(orchard), - 'buildenv': file_receipt(Path(orchard) / 'src/orchard/buildenv.py'), - 'wrapper': file_receipt(Path(project) / 'desktop/snapshot_buildenv.py')} diff --git a/release/recovery_audit.py b/release/recovery_audit.py deleted file mode 100644 index 6c061c1..0000000 --- a/release/recovery_audit.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Read-only checks inside the actual framed initramfs, without extracting it.""" -import gzip -import hashlib -import json -from pathlib import Path, PurePosixPath -import stat - - -def read_newc(stream): - total = 0 - seen = set() - while True: - header = stream.read(110) - if len(header) != 110 or header[:6] != b'070701': - raise ValueError('Invalid/truncated recovery newc header') - fields = [int(header[i:i+8], 16) for i in range(6, 110, 8)] - mode, size, name_size = fields[1], fields[6], fields[11] - if not 0 < name_size <= 4096 or size > 256 * 1024**2 or len(seen) > 100000: - raise ValueError('Recovery member exceeds limits') - label = stream.read(name_size) - if len(label) != name_size or label[-1:] != b'\0' or b'\0' in label[:-1]: - raise ValueError('Invalid recovery member name') - name = label[:-1].decode() - stream.read(-(110 + name_size) % 4) - if name == 'TRAILER!!!': - if size or stream.read(513) != b'\0' * 512: - raise ValueError('Unexpected recovery trailer data') - return - path = PurePosixPath(name) - if str(path) == '.' or path.is_absolute() or '..' in path.parts or name in seen or fields[2:4] != [0, 0]: - raise ValueError('Unsafe/duplicate/non-root recovery member') - if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode) or stat.S_ISLNK(mode)): - raise ValueError('Special recovery member') - seen.add(name) - data = stream.read(size) - if len(data) != size: - raise ValueError('Truncated recovery member') - stream.read(-size % 4) - total += size - if total > 1024**3: - raise ValueError('Recovery expansion exceeds limit') - yield name, mode, data - - -def audit_runtime(image, kernel_release, release, private): - """The recovery's runtime: endpoints, this release, KERNEL_RELEASE's modules, no PRIVATE firmware.""" - receipt = json.loads(Path(str(image) + '.json').read_text()) - part = next(row for row in receipt['components'] if row['name'] == 'initramfs') - hashes, modes, records = {}, {}, {} - names = set() - with image.open('rb') as raw: - raw.seek(part['offset']) - with gzip.GzipFile(fileobj=raw) as stream: - for name, mode, data in read_newc(stream): - names.add(name) - modes[name] = mode - if name.removeprefix('usr/') in private: - raise ValueError('Private firmware found in recovery') - if stat.S_ISREG(mode): - if name.startswith(('run/orchard/', 'run/recovery/', 'root/.ssh/')): - raise ValueError('Private session state found in public recovery') - hashes[name] = hashlib.sha256(data).hexdigest() - if name in ('etc/orchard/release.json', 'etc/orchard/public.SHA256SUMS'): - records[name] = data - prefix = 'usr/libexec/orchard/' - required = {prefix + name for name in ('device.py', 'identity.py', 'storage-fence', 'adt.py')} - required |= {'init', 'usr/bin/orchard-device', 'sbin/capture-adt', 'sbin/authorize-key', - 'sbin/recovery-start-ssh', 'usr/bin/python3', - 'usr/sbin/blkid', 'usr/sbin/sfdisk', 'usr/sbin/mke2fs', 'etc/mke2fs.conf'} - if not required <= names: - raise ValueError('Missing recovery endpoint/runtime') - for name in ('init', 'usr/bin/orchard-device', 'sbin/capture-adt', 'sbin/authorize-key', 'sbin/recovery-start-ssh', - prefix + 'storage-fence', 'usr/sbin/blkid', 'usr/sbin/sfdisk', 'usr/sbin/mke2fs'): - if not stat.S_ISREG(modes[name]) or modes[name] & 0o111 != 0o111: - raise ValueError('Recovery helper is not executable: ' + name) - if any('omarchy-j71b' in name or 'orchard-j71b' in name or name == 'usr/bin/omarchy-device' for name in names): - raise ValueError('Old agent namespace remains in recovery') - # The host checks the root's hash; the recovery only has to belong to this - # release and run the kernel of its platform's layer. - config = json.loads(records['etc/orchard/release.json']) - if (config['release'] != release or config['protocol'] != 1 or - config['kernel_release'] != kernel_release): - raise ValueError('Recovery does not belong to this release') - public = records['etc/orchard/public.SHA256SUMS'].decode() - for line in public.splitlines(): - digest, name = line.split(' ', 1) - if hashes.get(name.lstrip('/')) != digest: - raise ValueError('Recovery public runtime differs: ' + name) - return {'members': len(names), 'hashes': hashes, 'modes': modes, 'public': public} diff --git a/system/armv7/bootstrap.json b/system/armv7/bootstrap.json index 757a58d..cf52ec1 100644 --- a/system/armv7/bootstrap.json +++ b/system/armv7/bootstrap.json @@ -1,8 +1,15 @@ { - "schema": 1, + "schema": 2, "architecture": "armv7h", - "url": "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz", - "signature_url": "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz.sig", + "build_date": "2026-08-05", + "urls": [ + "https://github.com/OrchardLinux/omarchy-a/releases/download/armv7-bootstrap-20260805/ArchLinuxARM-armv7-20260805.tar.gz", + "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz" + ], + "signature_urls": [ + "https://github.com/OrchardLinux/omarchy-a/releases/download/armv7-bootstrap-20260805/ArchLinuxARM-armv7-20260805.tar.gz.sig", + "http://os.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz.sig" + ], "bytes": 770429322, "sha256": "ebe0fa119dea45e3af0aa61785676bdbf417bc5f754d529b87255dbf0ca4e3f0", "signature_sha256": "0d4bb680d381c8053c378fad27cedb67446b39b7bff1c87257ebf6743db0fd94", diff --git a/system/build-armv7-builder b/system/build-armv7-builder index c9bcdda..b58cb5c 100755 --- a/system/build-armv7-builder +++ b/system/build-armv7-builder @@ -3,6 +3,8 @@ The selected Podman connection must already execute ARM32 binaries. This command does not install an emulator or change the host's default connection. +The bootstrap is one dated Arch Linux ARM build: its copy in this repository's +releases first, then upstream's "latest" while that is still the same build. """ import argparse import hashlib @@ -46,6 +48,24 @@ def verify(archive, signature, pin): return pin['sha256'] +def fetch_first(urls, path, maximum, pinned): + """The first of URLS whose bytes are PINNED's sha256 (the dated copy, then upstream), into PATH.""" + if path.exists(): + return path + failures = [] + for url in urls: + try: + fetch(url, path, maximum) + except (OSError, ValueError) as error: + failures.append(f'{url}: {error}') + continue + if sha(path) == pinned: + return path + path.unlink() + failures.append(f'{url}: another build than the pinned one') + raise ValueError('No source has the pinned ARMv7 bootstrap: ' + '; '.join(failures)) + + def fetch(url, path, maximum): if path.exists(): return path @@ -83,9 +103,10 @@ def main(): parser.error('Choose a new compiler receipt path') pin = json.loads((INPUTS / 'bootstrap.json').read_text()) args.cache.mkdir(parents=True, exist_ok=True) - archive = args.bootstrap or fetch(pin['url'], args.cache / 'bootstrap.tar.gz', pin['bytes']) + archive = args.bootstrap or fetch_first(pin['urls'], args.cache / 'bootstrap.tar.gz', pin['bytes'], pin['sha256']) signature = args.signature or (Path(str(args.bootstrap) + '.sig') if args.bootstrap else - fetch(pin['signature_url'], args.cache / 'bootstrap.tar.gz.sig', 16384)) + fetch_first(pin['signature_urls'], args.cache / 'bootstrap.tar.gz.sig', 16384, + pin['signature_sha256'])) verify(archive, signature, pin) result = {'schema': 1, 'architecture': 'armv7h', 'bootstrap': pin, 'signature_valid': True} if not args.verify_only: diff --git a/tests/test_armv7_bootstrap.py b/tests/test_armv7_bootstrap.py index 7795d77..c1870d7 100644 --- a/tests/test_armv7_bootstrap.py +++ b/tests/test_armv7_bootstrap.py @@ -64,6 +64,27 @@ def test_download_bound_removes_partial_file(self): self.assertFalse(target.exists()) self.assertFalse(target.with_suffix('.part').exists()) + def test_a_refreshed_upstream_falls_back_to_the_dated_copy_and_back(self): + module = producer() + with tempfile.TemporaryDirectory() as directory: + target = Path(directory) / 'archive' + served = {'https://copy.invalid/dated': b'pinned build', 'http://upstream.invalid/latest': b'newer build'} + pinned = hashlib.sha256(b'pinned build').hexdigest() + with patch.object(module, 'urlopen', side_effect=lambda url, timeout: io.BytesIO(served[url])): + module.fetch_first(['http://upstream.invalid/latest', 'https://copy.invalid/dated'], target, 100, pinned) + self.assertEqual(target.read_bytes(), b'pinned build') + target.unlink() + with self.assertRaisesRegex(ValueError, 'another build'): + module.fetch_first(['http://upstream.invalid/latest'], target, 100, pinned) + self.assertFalse(target.exists()) + + def test_the_pin_names_a_dated_copy_before_upstream_latest(self): + import json + pin = json.loads((PROJECT / 'system/armv7/bootstrap.json').read_text()) + self.assertNotIn('latest', pin['urls'][0]) + self.assertIn(pin['build_date'].replace('-', ''), pin['urls'][0]) + self.assertEqual(len(pin['urls']), len(pin['signature_urls'])) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_release_provenance.py b/tests/test_release_provenance.py deleted file mode 100644 index 791e7e0..0000000 --- a/tests/test_release_provenance.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Assembly provenance measures the source and component bytes it records.""" -import hashlib -import json -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -from release.provenance import component_receipt, source_identity, orchard_identity, input_revision -from release import provenance - - -class Provenance(unittest.TestCase): - def test_component_manifest_digest_cannot_hide_changed_member_bytes(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - member = root / 'helper' - member.write_bytes(b'original') - manifest = root / 'manifest.json' - manifest.write_text(json.dumps({'schema': 1, 'files': { - 'helper': hashlib.sha256(member.read_bytes()).hexdigest()}})) - record = component_receipt(root) - self.assertEqual(record['manifest']['sha256'], hashlib.sha256(manifest.read_bytes()).hexdigest()) - self.assertEqual(record['files']['helper']['bytes'], 8) - member.write_bytes(b'changed') - with self.assertRaisesRegex(ValueError, 'differs from manifest'): - component_receipt(root) - - def test_unversioned_source_snapshot_records_exact_tree_content(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'recipe.py').write_text('first version\n') - first = source_identity(root) - (root / 'recipe.py').write_text('second version\n') - second = source_identity(root) - self.assertIsNone(first['commit']) - self.assertEqual(first['files'], 1) - self.assertNotEqual(first['tree_sha256'], second['tree_sha256']) - - def test_orchard_source_archive_keeps_null_revision_and_exact_tree_identity(self): - with tempfile.TemporaryDirectory() as temporary: - parent = Path(temporary) - subprocess.run(['git', 'init', '-q', str(parent)], check=True) - (parent / '.gitignore').write_text('out/\n') - root = parent / 'out/archive' - (root / 'src/orchard').mkdir(parents=True) - source = root / 'src/orchard/buildenv.py' - source.write_text('source archive version one\n') - # An archive inside another checkout must keep its own unversioned identity. - with patch.object(provenance.snapshot, 'revision', side_effect=AssertionError('parent Git inherited')): - initial = orchard_identity(root) - self.assertEqual(input_revision(initial, {'commit': None, 'modified': None}), - {'commit': None, 'modified': None}) - self.assertEqual(len(initial['tree_sha256']), 64) - self.assertEqual(initial['files'], 1) - with self.assertRaisesRegex(ValueError, 'matching null/null'): - input_revision(initial) - with self.assertRaisesRegex(ValueError, 'inputs revision differs'): - input_revision(initial, {'commit': 'a' * 40, 'modified': False}) - source.write_text('source archive version two\n') - self.assertNotEqual(orchard_identity(root)['tree_sha256'], initial['tree_sha256']) - - def test_dirty_orchard_git_source_remains_rejected(self): - dirty = {'commit': 'a' * 40, 'modified': True, 'tree_sha256': 'b' * 64, 'files': 1} - with patch.object(provenance, 'source_identity', return_value=dirty), \ - self.assertRaisesRegex(ValueError, 'clean Orchard'): - orchard_identity(Path('/source')) - - -if __name__ == '__main__': - unittest.main() From 20beedeba75191e12644abbf686400479d32906b Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Sat, 7 Nov 2026 20:32:16 +0800 Subject: [PATCH 13/14] armv7h: a release is the root as a block image, from orchard's 32-bit index The 32-bit release now has the arm64 release's shape: the root as a block image (orchard.image.root_image), its logo, and the platform layers, loaders and install tools orchard's 32-bit index lists. Orchard is the device layer, so the distro builds no recovery, selects no board and carries no LwVM inputs or per-board platform layer. Dropped: the Cascadia recovery and its audit, selects-device and --device, the LwVM inputs, the native platform-layer producer, the recovery branch of the manifest, the assembly provenance checks (they refused modified orchard checkouts) and the per-file ELF/BPF checks of the root. Kept: the architecture choice, the ARMv7 builder (default builder image for armv7h), desktop/target.py, system/armv7, Omacalc, and the architecture checks of the desktop components, base and static tools. --- BUILD.md | 83 +++--- desktop/armv7/README.md | 24 +- docs/releasing.md | 13 +- orchard.toml | 1 - release/audit.py | 248 ++---------------- release/build.py | 393 ++++------------------------- release/manifests.py | 68 ++--- release/public_root.py | 27 -- tests/test_architecture.py | 100 ++------ tests/test_release_architecture.py | 337 +------------------------ tests/test_release_contracts.py | 308 +++++----------------- tools/build.py | 119 +++------ 12 files changed, 284 insertions(+), 1437 deletions(-) diff --git a/BUILD.md b/BUILD.md index ed6c7bd..9e4138a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -17,10 +17,10 @@ orchard build [--version VERSION] builds a release into `out/releases/VERSION` (default `dev-DATE-TIME`) through `tools/build.py`, the entry `orchard.toml` names: each component below is kept in `out/components/NAME/KEY/`, keyed by its files, and rebuilt only when they -change; the release is assembled and audited every time. For the public -AArch64 flow, `orchard run` starts the selected kernel on the attached device -and `orchard install` installs the release. The rest of this page describes -the producer steps, including the gated ARMv7 assets. +change; the release is assembled and audited every time. `orchard run` starts +the selected kernel on the attached device and `orchard install` installs the +release. `orchard build --platform apple-armv7` builds the 32-bit (armv7h) +release instead. The rest of this page describes the producer steps. ## Local setup @@ -48,23 +48,20 @@ Python 3.11. - `desktop/build-sources` and `desktop/build`: desktop applications and payload. - `system/build-rootfs`: signed Arch Linux ARM base and package receipt; see [system/README.md](system/README.md). - orchard supplies the kernel, DTBs and modules of every platform, its platform layer, and the static BusyBox/Dropbear recipes; see [orchard's build instructions](https://github.com/OrchardLinux/orchard/blob/main/BUILD.md). Local assembly builds or reuses each platform's kernel through orchard's shared cache. Build the static tools separately; supply explicit kernel bundles only when overriding that default. -- `python boot/build-artwork --output DIR`: the Omarchy mark for AArch64's m1n1 flow: `splash` (480 px, painted for an installed boot entry) and `logo.bin` (256 and 128 px). AArch64 releases use Orchard's `quiet/m1n1.bin` from the component bundle. -- AArch64 releases ship Orchard's `quiet/pongo.bin` from the component bundle. ARMv7 uses its Cascadia recovery assets. +- `python boot/build-artwork --output DIR`: the Omarchy mark: `splash` (480 px, painted for an installed boot entry) and `logo.bin` (256 and 128 px). +- No loader is built here: a release names orchard's loaders as orchard's index lists them (arm64: Pongo and m1n1). Retain each component's receipt. Do not change hardware activation or timing as part of presentation changes. ## Assemble The following example uses distro-owned components collected under `out/components/`. -A release serves one userspace architecture. AArch64 uses Orchard's platform -index and block-image producer: `root.blocks.zst`, `root.map.json`, the -selected platform layers, m1n1, Pongo and install tools. Orchard supplies these -files through `orchard.image.platforms_index`. - -ARMv7 takes a selected-board input record and calls Orchard's native platform -and recovery producers. Its assets contain `rootfs.tar.zst`, a Cascadia recovery -and a platform layer with the LwVM initrd. The public platform index serves the -AArch64 installer contract. +A release has one root of one userspace architecture, and the same shape for +both: the root as a block image (`root.blocks.zst`, `root.map.json`), its logo, +and orchard's platform layers, loaders and install tools, which +`orchard.image.platforms_index` supplies from orchard's index of that +architecture (`platforms.toml` for arm64, the separate 32-bit index for +armv7h). No recovery image is part of a release. The kernels come from the same orchard builder and cache as `orchard build` in orchard's examples; matching inputs reuse the compiled bundles. Boot artwork, userspace and assembly remain owned by this repository; the m1n1 is orchard's. @@ -93,40 +90,22 @@ To use a staged kernel bundle, pass `--kernel-dir PLATFORM=DIRECTORY` to `orchard.inputs`. Select a subset there with `--platform PLATFORM`; the index consumes that document's exact selection. `release.build --orchard-release TAG` takes finished AArch64 files from a published Orchard build and accepts -`--platform PLATFORM` to select its layers. The cache respects `XDG_CACHE_HOME` +`--platform PLATFORM` to select its layers. `--architecture armv7h` builds the +32-bit release; from a published build it names its platforms +(`--platform apple-armv7`). The cache respects `XDG_CACHE_HOME` and `ORCHARD_CACHE_DIR`, just like RAM builds. -Choose a new version and output directory for each build. Root builds first; -the matching asset producers follow. `release.toml` and the index are written -last. A native recovery embeds the root's exact stream receipt. -The cached base retains `base-` in `etc/ipadlinux/release`; -`etc/orchard/release.json` identifies the final release. Assembly verifies the -base stamp against its receipt. `build-receipt.json` records both stamps, -Omarchy's source identity, measured component manifests and the final root hash. - -For the ARMv7 compiler and selected-board build, see -[ARMv7 desktop components](desktop/armv7/README.md). The producer checks -Orchard's inputs before compiling the desktop or assembling the root. A selected -input record with `root_format = "lwvm"` must supply the native static helper -at `tools.lwvm`; it is passed to both the installed initrd and RAM recovery. -An offline build can provide it through `tools/build.py --platform-lwvm -PLATFORM=FILE`. The selected Dropbear pair supplies early SSH recovery. -The snapshot must expose the installed-root and recovery mapper options before -component compilation starts. ARMv7 requires a selected `--inputs` document -with one board, its Cascadia loader and native static tools. - -The native installed-root path requires Orchard's -[ARMv7 producers](https://github.com/OrchardLinux/orchard/pull/82), -[Cascadia boot transport](https://github.com/OrchardLinux/orchard/pull/84), -[LwVM platform layer](https://github.com/OrchardLinux/orchard/pull/92) and -[native recovery installer](https://github.com/OrchardLinux/orchard/pull/94). -Its input record includes `architecture = "arm"`, `image = "zImage"` and -`root_format = "lwvm"`, with the static mapper at `tools.lwvm`. - -The generic web installer implements `checkm8-pongo`; the ARMv7 build emits -the native root, layer and recovery assets. P105's public installation gate -remains closed. Public install and launch support for its boot method remains -open. Physical qualification must bind these assets to an exact source revision. +Choose a new version and output directory for each build. Root builds first, +then orchard's files and the block image; `release.toml` and the index are +written last. The cached base retains `base-` in +`etc/ipadlinux/release`; `etc/orchard/release.json` identifies the final +release. + +For the ARMv7 compiler and build, see +[ARMv7 desktop components](desktop/armv7/README.md). Building the armv7h +desktop and base runs ARMv7 code: the builder image needs a Podman connection +that executes ARM32 binaries (emulated on an arm64 host, 1 to 3 hours). The +release step itself runs only arm64 tools. ## Validate without a device @@ -136,12 +115,10 @@ python -m release.audit out/releases/0.1.0-orchard.3/release.toml PYTHONPATH=$ORCHARD_CHECKOUT/src python -m orchard.manifests --verify-local out/releases/0.1.0-orchard.3/index.toml ``` -The auditor checks AArch64's block image, map and installer assets. ARMv7 adds -checks of the recovery's endpoints, Cascadia framing and matching modules. -Both paths verify platform layers against their receipts and SHA256SUMS, -desktop integrity, package privileges and private-data exclusions. -LwVM checks require matching native mapper and SSH helper bytes in the -installed initrd and RAM recovery. +The auditor checks the block image, map, logo, loaders and install tools, the +platform layers against their receipts and SHA256SUMS, desktop integrity, +package privileges, that the desktop helpers are of the root's architecture, +and the absence of private session and firmware data. Headless finished-artifact checks use Podman without network or device passthrough: diff --git a/desktop/armv7/README.md b/desktop/armv7/README.md index c22e84d..60c1ba8 100644 --- a/desktop/armv7/README.md +++ b/desktop/armv7/README.md @@ -20,16 +20,17 @@ with the dependencies from `desktop/install-toolchain` and repository's pinned AArch64 container is selected by AArch64 builds. `system/build-armv7-builder` prepares that compiler image from the official -Arch Linux ARM bootstrap pinned in `system/armv7/bootstrap.json`. It verifies -the archive's SHA-256 and detached signature against Arch Linux ARM's build -key before importing it. The selected Podman connection must already support +Arch Linux ARM bootstrap pinned in `system/armv7/bootstrap.json`: one dated +build, fetched from its copy in this repository's releases (upstream only +publishes a "latest" that it replaces), else from upstream while that is still +the same build. It verifies the archive's SHA-256 and detached signature +against Arch Linux ARM's build key before importing it. The selected Podman connection must already support ARM32 execution. An emulator is a host prerequisite; the recipe leaves its registration and the default connection to the person managing the host. ```sh system/build-armv7-builder --receipt out/armv7-builder.json -python3 tools/build.py --orchard /path/to/orchard --device p105 \ - --builder-image localhost/omarchy-a-arch-builder:armv7h \ +python3 tools/build.py --orchard /path/to/orchard --platform apple-armv7 \ --version armv7-development --output out/armv7-development ``` @@ -38,17 +39,20 @@ set `CONTAINER_CONNECTION=NAME` for the release build. The compiler receipt records its image ID and installed package versions. Component cache keys include the target architecture and the selected compiler image ID. +`tools/build.py` uses `localhost/omarchy-a-arch-builder:armv7h`, what this +recipe makes, unless `--builder-image` names another. + Each release contains one userspace ISA. Selecting `apple-armv7` chooses `armv7h`; mixing it with AArch64 platforms is rejected before components -build. A Cascadia recovery also needs one selected board, because its loader -uses that board's handoff profile. Orchard's companion ARMv7 producer change -provides the loader and static tools through its per-platform input record. +build. The release is the root as a block image with orchard's 32-bit +platform layer, loaders and install tools, like the arm64 one; orchard's +inputs supply its armv7 static tools (BusyBox, Dropbear). The producers check the compiler's machine and the resulting ELF headers. Package verification checks `.PKGINFO` and the ELF headers inside each package, including the ARM EABI5 hard-float flags. Each component receipt records the -userspace architecture. A foreign package or executable stops assembly before -it reaches a root filesystem. +userspace architecture, and the release refuses a component, base or static +tool of another one. These source recipes require a full ARMv7 component build and desktop smoke check before hardware qualification. Tests of the architecture guards use diff --git a/docs/releasing.md b/docs/releasing.md index 2f251ec..4f12a9e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,12 +1,9 @@ # Publishing Omarchy A Build and validate a coherent local release using [BUILD.md](../BUILD.md). Its -root and boot assets must come from that build. The public workflow below -serves AArch64's block-image installer. - -ARMv7 produces local root archive, platform layer and Cascadia recovery assets. -Its public installation and launch method remains gated in Orchard. Keep those -assets with their audit receipts until that method is integrated and qualified. +root and orchard's files must come from that build. The arm64 and the 32-bit +(armv7h) release are two releases of the same shape, each its root as a block +image; the 32-bit one is not published by the workflow below yet. ## GitHub builds @@ -44,13 +41,13 @@ https://github.com/OrchardLinux/omarchy-a/releases/latest/download/index.toml Only that entry point follows latest. Its manifest and image references point to specific release tags, preventing cross-release mixing. -Every public AArch64 release carries its installer: `install.sh` and `orchard-install.pyz` (orchard's core with this release's index, device data and launch loaders), made by `orchard.installer` from the orchard release CI pins and rewritten by `release.publish` for the release's URLs. The README's command runs the latest release's `install.sh`; the repository's `install.sh` only forwards to it, for older instructions. Publish the pinned [orchard release](https://github.com/OrchardLinux/orchard/blob/main/docs/releases.md) first: its tag provides the components and the loaders. Change the README's install command only once a release with `install.sh` is published. +Every public release carries its installer: `install.sh` and `orchard-install.pyz` (orchard's core with this release's index, device data and launch loaders), made by `orchard.installer` from the orchard release CI pins and rewritten by `release.publish` for the release's URLs. The README's command runs the latest release's `install.sh`; the repository's `install.sh` only forwards to it, for older instructions. Publish the pinned [orchard release](https://github.com/OrchardLinux/orchard/blob/main/docs/releases.md) first: its tag provides the components and the loaders. Change the README's install command only once a release with `install.sh` is published. Before announcing a release: - Resolve its hosted index with `orchard resolve https://github.com/OrchardLinux/omarchy-a/releases/latest/download/index.toml` from a clean host environment. - Test installation and `orchard launch` after a cold restart on supported hardware; do not infer device qualification from unit tests or container checks. -- Confirm the published root and boot assets come from the audited build and retain their build receipts, source pins, licenses and corresponding source/build inputs. +- Confirm the published root and orchard files come from the audited build and retain their build receipts, source pins, licenses and corresponding source/build inputs. - Include the experimental status and the README's automatic-login/passwordless-sudo warning in release notes. ## Updates diff --git a/orchard.toml b/orchard.toml index 3910934..89cf802 100644 --- a/orchard.toml +++ b/orchard.toml @@ -5,4 +5,3 @@ name = "omarchy-a" [release] build = "tools/build.py" selects-platform = true # tools/build.py takes --platform NAME ... or --platform all -selects-device = true # Cascadia payloads use the selected board's handoff profile diff --git a/release/audit.py b/release/audit.py index 12f90d7..b798499 100644 --- a/release/audit.py +++ b/release/audit.py @@ -1,166 +1,17 @@ """Offline audit of finished assets: namespace safety, integrity and boot contracts.""" import argparse -import gzip import hashlib -import io import json from pathlib import Path, PurePosixPath import subprocess -import struct -import stat import tarfile -import zlib from release import snapshot +from release.architecture import validate from release.manifests import read_release from .common import PROJECT -from .public_root import sha, check_root_elf -from .recovery_audit import audit_runtime, read_newc -from .build import selected_root_format -from release.architecture import validate, check_elf - -# m1n1's payload framing (orchard docs/ram-development.md, "Assembling by hand"). -INITRAMFS_MAGIC = b'm1n1_initramfs' -LOGO_MAGIC = b'm1n1_logo_256128' -CASCADE_PADDING = {'loader-padding', 'kernel-padding', 'dtb-padding'} - - -def dtb_initrd(data): - """Read /chosen's initrd range from the checked binary tree framing.""" - if len(data) < 40: - raise ValueError('Truncated Cascadia DTB') - magic, total, tree, strings, _, version, last, _, names_size, tree_size = struct.unpack_from('>10I', data) - if (magic != 0xd00dfeed or total != len(data) or version < 17 or last > 17 or - any(start < 40 or size <= 0 or start + size > total - for start, size in ((tree, tree_size), (strings, names_size)))): - raise ValueError('Invalid Cascadia DTB bounds') - names, end, stack, values, cells, finished = data[strings:strings + names_size], tree + tree_size, [], {}, 2, False - while tree < end: - if tree + 4 > end: - raise ValueError('Truncated Cascadia DTB token') - token = struct.unpack_from('>I', data, tree)[0] - tree += 4 - if token == 1: - stop = data.find(b'\0', tree, end) - if stop < 0: - raise ValueError('Unterminated Cascadia DTB node') - stack.append(data[tree:stop]) - tree = (stop + 4) & ~3 - elif token == 2: - if not stack: - raise ValueError('Unbalanced Cascadia DTB') - stack.pop() - elif token == 3: - if not stack or tree + 8 > end: - raise ValueError('Truncated Cascadia DTB property') - length, at = struct.unpack_from('>II', data, tree) - tree += 8 - stop = names.find(b'\0', at) - if at >= len(names) or stop < 0 or tree + length > end: - raise ValueError('Invalid Cascadia DTB property') - name, value = names[at:stop], data[tree:tree + length] - if stack == [b''] and name == b'#address-cells': - if len(value) != 4 or (cells := int.from_bytes(value, 'big')) not in (1, 2): - raise ValueError('Invalid Cascadia address cells') - if stack == [b'', b'chosen'] and name in (b'linux,initrd-start', b'linux,initrd-end'): - if name in values: - raise ValueError('Duplicate Cascadia initrd property') - values[name] = value - tree = (tree + length + 3) & ~3 - elif token == 4: - continue - elif token == 9: - if stack or tree != end: - raise ValueError('Cascadia DTB ends inside a node') - finished = True - break - else: - raise ValueError('Unknown Cascadia DTB token') - if not finished or len(values) != 2 or any(len(value) != cells * 4 for value in values.values()): - raise ValueError('Cascadia DTB lacks its initrd range') - return tuple(int.from_bytes(values[key], 'big') for key in (b'linux,initrd-start', b'linux,initrd-end')) - - -def audit_cascadia(image, parts, receipt): - required = {'loader', 'kernel', 'dtb', 'initramfs'} - if (receipt.get('architecture') != 'arm' or receipt.get('initramfs_encoding') != 'cascadia-dtb' or - set(parts) - CASCADE_PADDING != required): - raise ValueError('Invalid Cascadia recovery framing') - loader = parts['loader'][0] - if loader['offset'] or loader['bytes'] < 0x7f00 + 68 or loader['bytes'] > 0x8000: - raise ValueError('Invalid Cascadia loader size') - with image.open('rb') as stream: - if stream.read(4) != bytes.fromhex('c0000cf1'): - raise ValueError('Cascadia loader entry differs') - stream.seek(0x7f00) - raw = stream.read(68) - magic, version, size, base, kernel, dtb, dtb_size, initrd, initrd_size, target, image_size, _, _, ram_first, ram_end, limit, crc = struct.unpack('<4s16I', raw) - if magic != b'CB32' or version != 1 or size != 68 or zlib.crc32(raw[:-4]) != crc: - raise ValueError('Invalid Cascadia loader configuration') - if (kernel != 0x8000 or base % 0x8000 or target % 4096 or - not ram_first <= base < base + image_size <= target < limit <= ram_end or - target + initrd_size > limit or image_size != image.stat().st_size or - receipt.get('load_address') != base or receipt.get('kernel_entry') != base + kernel or - receipt.get('dtb_address') != base + dtb or receipt.get('initramfs_address') != target): - raise ValueError('Cascadia handoff addresses differ') - for name, offset, length in (('kernel', kernel, None), ('dtb', dtb, dtb_size), ('initramfs', initrd, initrd_size)): - row = parts[name][0] - if row['offset'] != offset or (length is not None and row['bytes'] != length): - raise ValueError('Cascadia configuration differs from component: ' + name) - kernel_header = parts['kernel'][1] - if (len(kernel_header) < 48 or struct.unpack_from(' 32 * 1024 * 1024: - raise ValueError(f'{label}: static helper exceeds its inspection bound') - data = path.read_bytes() - check_elf(data, architecture, label) - arm = data[4] == 1 - offset = struct.unpack_from(' len(data): - raise ValueError(f'{label}: invalid ELF program headers') - loadable = False - for index in range(count): - start = offset + index * size - kind = struct.unpack_from(' len(data): - raise ValueError(f'{label}: invalid ELF dynamic segment') - for position in range(dynamic, dynamic + length, stride): - tag = struct.unpack_from('= 64 and data[:7] == b'\x7fELF\x02\x01\x01' and - struct.unpack_from('= sections or - offset < 64 or offset + section_size * sections > len(data)): - raise ValueError('Invalid BPF data object section table: ' + name) - for index in range(sections): - start = offset + index * section_size - kind = struct.unpack_from(' len(data): # SHT_NOBITS has no file data. - raise ValueError('Truncated BPF data object section: ' + name) - return 'bpf' - return check_elf(data, architecture, name, executable=False) - - def desktop_replacements(): prefix = 'home/tablet/.local/share/omarchy-ipad/' replacements = {prefix + name: (PROJECT / 'desktop/shell' / name).read_bytes() for name in ( @@ -253,10 +230,6 @@ def add(member, data=None): base_stamp = data.decode().rstrip('\n') if record.get('release') is not None and data != (record['release'] + '\n').encode(): raise ValueError('Base release stamp differs from its receipt') - if data is not None and data.startswith(b'\x7fELF'): - check_root_elf(data, architecture, member.name) - if architecture == 'armv7h' and member.name == 'usr/lib/systemd/systemd': - check_elf(data, architecture, member.name) if member.name in ('etc/group', 'etc/gshadow'): data = group_membership(data, shadow=member.name == 'etc/gshadow') if member.name in replacements: diff --git a/tests/test_architecture.py b/tests/test_architecture.py index d959e03..b3921a6 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -116,15 +116,13 @@ def test_build_propagates_isa_and_separates_component_cache_keys(self): root = Path(folder) orchard = root / 'orchard' (orchard / 'src/orchard').mkdir(parents=True) + platform(orchard, 'apple-16k', 'arm64') platform(orchard, 'apple-armv7', 'arm') - device = orchard / 'devices/p105/device.toml' - device.parent.mkdir(parents=True) - device.write_text('[kernel]\nplatform = "apple-armv7"\n') - calls, keys = [], {} + calls, keys, inputs = [], {}, [] tools = root / 'arm' tools.mkdir() - for name in ('cascadia.bin', *entry.TOOLS): - (tools / name).write_bytes(b'loader' if name == 'cascadia.bin' else static_executable('armv7h')) + for name in ('busybox', 'dropbear', 'dropbearkey'): + (tools / name).write_bytes(static_executable('armv7h')) def component(project, name, inputs, produce, *, artifact, extra=()): target = root / 'components' / name / artifact @@ -134,96 +132,34 @@ def component(project, name, inputs, produce, *, artifact, extra=()): def command(module, *args): if module == 'orchard.inputs': - self.assertEqual(args[args.index('--device') + 1], 'p105') + inputs.append(args) path = Path(args[args.index('--output') + 1]) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({'platforms': {'apple-armv7': { - 'boards': {'p105': 's5l8942x-p105'}, - 'tools': {name: str(tools / name) for name in - ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw')}, - 'loader': {'kind': 'cascadia', 'file': str(tools / 'cascadia.bin')}}}})) + path.write_text(json.dumps({'platforms': {'apple-armv7': {'boards': {'p105': 's5l8942x-p105'}}}, + 'shared': {'tools': {name: str(tools / name) for name in + ('busybox', 'dropbear', 'dropbearkey')}}})) argv = ['build', '--version', 'test', '--output', str(root / 'release'), - '--orchard', str(orchard), '--device', 'p105'] + '--orchard', str(orchard), '--platform', 'apple-armv7'] with patch.dict(os.environ, dict(os.environ)), patch.object(sys, 'argv', argv), patch.object(entry, 'PROJECT', root), \ patch.object(entry, 'component', component), patch.object(entry, 'orchard_command', command), \ - patch.object(entry, 'require_native_contract'), \ - patch.object(entry, 'producer_identity', return_value=PRODUCER), \ + patch.object(entry, 'builder_key', side_effect=lambda image: 'id:' + (image or '')), \ patch.object(entry, 'run', side_effect=lambda *args: calls.append(tuple(map(str, args)))): entry.main() + self.assertEqual(inputs[0][-2:], ('--platform', 'apple-armv7')) for name in ('desktop', 'cursor', 'labwc', 'base'): self.assertIn('armv7h', keys[name]) - for name in ('desktop', 'cursor', 'labwc'): - self.assertIn(json.dumps(PRODUCER, sort_keys=True), keys[name]) + self.assertIn('id:' + entry.ARMV7_BUILDER, keys[name]) for script in ('desktop/build', 'desktop/build-cursor-helper', 'desktop/build-labwc', 'system/build-rootfs'): call = next(call for call in calls if script in call) self.assertEqual(call[call.index('--architecture') + 1], 'armv7h') + self.assertEqual(call[call.index('--builder-image') + 1], entry.ARMV7_BUILDER) release = next(call for call in calls if 'release.build' in call) - self.assertEqual(release[release.index('--loader') + 1], str(tools / 'cascadia.bin')) - self.assertNotIn('--pongo', release) - self.assertEqual(release[release.index('--base-release') + 1], 'base-' + 'f' * 16) - recorded = json.loads(Path(release[release.index('--component-keys') + 1]).read_text()) - self.assertEqual(set(recorded['components']), {'desktop', 'cursor', 'bluetooth', 'labwc', 'base', 'artwork'}) - self.assertEqual(recorded['producer'], PRODUCER) - - def test_missing_lwvm_input_stops_before_root_or_component_build(self): - entry = build_entry() - with tempfile.TemporaryDirectory() as folder: - root = Path(folder) - orchard = root / 'orchard' - (orchard / 'src/orchard').mkdir(parents=True) - platform(orchard, 'apple-armv7', 'arm') - with (orchard / 'platforms/apple-armv7.toml').open('a') as descriptor: - descriptor.write('[root]\nformat = "lwvm"\ndevices = ["p105"]\n') - device = orchard / 'devices/p105/device.toml' - device.parent.mkdir(parents=True) - device.write_text('[kernel]\nplatform = "apple-armv7"\n') - tools = root / 'arm' - tools.mkdir() - for name in ('cascadia.bin', *entry.TOOLS): - (tools / name).write_bytes(b'loader' if name == 'cascadia.bin' else static_executable('armv7h')) - - def command(module, *args): - self.assertEqual(module, 'orchard.inputs') - path = Path(args[args.index('--output') + 1]) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({'platforms': {'apple-armv7': { - 'root_format': 'lwvm', 'boards': {'p105': 's5l8942x-p105'}, - 'tools': {name: str(tools / name) for name in entry.TOOLS}, - 'loader': {'kind': 'cascadia', 'file': str(tools / 'cascadia.bin')}}}})) - - argv = ['build', '--version', 'test', '--output', str(root / 'release'), - '--orchard', str(orchard), '--device', 'p105'] - with patch.dict(os.environ, dict(os.environ)), patch.object(sys, 'argv', argv), \ - patch.object(entry, 'PROJECT', root), patch.object(entry, 'orchard_command', command), \ - patch.object(entry, 'require_native_contract'), \ - patch.object(entry, 'producer_identity', return_value=PRODUCER), \ - patch.object(entry, 'component') as component, \ - self.assertRaisesRegex(ValueError, 'requires tools.lwvm'): - entry.main() - component.assert_not_called() - - def test_native_cli_preflight_precedes_cold_input_production(self): - entry = build_entry() - with tempfile.TemporaryDirectory() as folder: - root = Path(folder) - (root / 'src/orchard').mkdir(parents=True) - platform(root, 'apple-armv7', 'arm') - with (root / 'platforms/apple-armv7.toml').open('a') as descriptor: - descriptor.write('[root]\nformat = "lwvm"\ndevices = ["p105"]\n') - device = root / 'devices/p105/device.toml' - device.parent.mkdir(parents=True) - device.write_text('[kernel]\nplatform = "apple-armv7"\n') - argv = ['build', '--version', 'test', '--output', str(root / 'out'), '--orchard', str(root), '--device', 'p105'] - with patch.dict(os.environ, dict(os.environ)), patch.object(sys, 'argv', argv), \ - patch.object(entry, 'orchard_command') as inputs, \ - patch.object(entry, 'component') as component, \ - patch.object(entry, 'require_native_contract', side_effect=ValueError('missing --lwvm')) as check, \ - self.assertRaisesRegex(ValueError, 'missing --lwvm'): - entry.main() - check.assert_called_once_with(root, lwvm_required=True) - inputs.assert_not_called() - component.assert_not_called() + self.assertEqual(release[release.index('--architecture') + 1], 'armv7h') + self.assertEqual(release[release.index('--dropbear') + 1], str(tools / 'dropbear')) + for removed in ('--device', '--loader', '--pongo', '--component-keys'): + self.assertNotIn(removed, release) + self.assertTrue(any('release.audit' in call for call in calls)) def test_base_cache_invalidates_when_either_package_helper_changes(self): entry = build_entry() diff --git a/tests/test_release_architecture.py b/tests/test_release_architecture.py index 6bbfae4..86a86ac 100644 --- a/tests/test_release_architecture.py +++ b/tests/test_release_architecture.py @@ -1,19 +1,12 @@ -"""Release boundaries reject a component or platform from another ISA.""" -import argparse -import gzip +"""Release boundaries reject a desktop component or root of another architecture.""" import hashlib -import io import json -from pathlib import Path, PurePosixPath -import shutil +from pathlib import Path import struct -import subprocess -import tarfile import tempfile import unittest -from unittest.mock import patch -from release import audit, build, manifests, public_root, snapshot +from release import manifests, public_root def elf(architecture): @@ -27,129 +20,7 @@ def elf(architecture): return bytes(raw) -def static_elf(architecture): - arm = architecture == 'armv7h' - data = bytearray(elf(architecture)) - offset, size = len(data), 32 if arm else 56 - data.extend(bytes(size)) - struct.pack_into('I', 1)}, 'children': []} - data, receipt = cascadia.assemble(bytes(loader), bytes(kernel), cascadia.write_fdt(tree), - gzip.compress(b'070701'), described, 'rdinit=/init') - image = root / 'recovery.bin' - image.write_bytes(data) - Path(str(image) + '.json').write_text(json.dumps(receipt)) - return image, receipt - - def test_cascadia_recovery_audit_checks_actual_component_bytes(self): - with tempfile.TemporaryDirectory() as directory: - image, receipt = self.cascadia_fixture(Path(directory)) - self.assertEqual(audit.audit_recovery(image, 'armv7h')['initramfs_encoding'], 'cascadia-dtb') - kernel = next(row for row in receipt['components'] if row['name'] == 'kernel') - with image.open('r+b') as output: - output.seek(kernel['offset'] + 36) - output.write(b'FAIL') - with self.assertRaisesRegex(ValueError, 'integrity mismatch'): - audit.audit_recovery(image, 'armv7h') - - def test_cascadia_config_is_checked_after_component_hashes_agree(self): - with tempfile.TemporaryDirectory() as directory: - image, receipt = self.cascadia_fixture(Path(directory)) - raw = bytearray(image.read_bytes()) - # Forge a valid configuration CRC naming the wrong DTB position. - struct.pack_into(' Date: Sat, 7 Nov 2026 21:17:23 +0800 Subject: [PATCH 14/14] armv7h: the manifest orchard's 32-bit index describes A 32-bit release names each board's Cascadia loader ([cascadia.BOARD], from the index's loaders.cascadia), boots with recovery-autogo and has no Pongo, m1n1 or logo (orchard docs/distribution-contract.md, "32-bit roots"). From a published orchard build it takes platforms-armv7.toml with --architecture arm. --- release/audit.py | 10 +++++++--- release/build.py | 10 +++++++--- release/manifests.py | 27 +++++++++++++++++---------- tests/test_release_architecture.py | 5 +++-- tests/test_release_contracts.py | 21 ++++++++++++++------- 5 files changed, 48 insertions(+), 25 deletions(-) diff --git a/release/audit.py b/release/audit.py index b798499..8624773 100644 --- a/release/audit.py +++ b/release/audit.py @@ -70,9 +70,13 @@ def audit(manifest, orchard=None): architecture = validate(root_receipt.get('architecture', 'aarch64')) root_path = asset(manifest, {'file': 'rootfs.tar.zst', **{key: value for key, value in root_receipt.items() if key in ('bytes', 'sha256')}}, 'root') - if architecture == 'aarch64' and not {'pongo', 'm1n1'} <= record['loaders'].keys(): - raise ValueError('An arm64 release boots with orchard\'s Pongo and m1n1') - for role, item in (('root image', record['root']), ('root map', record['root']['map']), ('logo', record['logo']), + if architecture == 'aarch64' and not ({'pongo', 'm1n1'} <= record['loaders'].keys() and record['logo']): + raise ValueError('An arm64 release boots with orchard\'s Pongo and m1n1, and has a logo') + if architecture == 'armv7h' and (record['boot_method'] != 'recovery-autogo' or record['logo'] or + not all(name.startswith('cascadia ') for name in record['loaders'])): + raise ValueError('A 32-bit release boots with each board\'s Cascadia loader, and has no logo') + for role, item in (('root image', record['root']), ('root map', record['root']['map']), + *((('logo', record['logo']),) if record['logo'] else ()), *record['loaders'].items(), ('install tools', record['install_tools'])): asset(manifest, item, role) platforms, layers = {}, {} diff --git a/release/build.py b/release/build.py index fb4de64..3e6ddcf 100644 --- a/release/build.py +++ b/release/build.py @@ -17,7 +17,7 @@ from .public_root import build as build_root, sha from release import snapshot from release.architecture import platform_arch, validate -from release.manifests import INDEXES, write as write_manifests +from release.manifests import INDEXES, ORCHARD_ARCHITECTURES, write as write_manifests def orchard_files(args): @@ -27,7 +27,10 @@ def orchard_files(args): a developer's own kernel. Only the former is linked instead of hosted when published. """ if args.orchard_release: - source = ('--release', args.orchard_release, *(part for name in args.platform for part in ('--platform', name))) + # --architecture only for 32-bit: the arm64 index is the default (and older orchards lack the option). + source = ('--release', args.orchard_release, + *(('--architecture', ORCHARD_ARCHITECTURES[args.architecture]) if args.architecture != 'aarch64' else ()), + *(part for name in args.platform for part in ('--platform', name))) else: source = ('--inputs', args.inputs) snapshot.command(args.orchard, 'orchard.image.platforms_index', args.output, *source, stdout=subprocess.DEVNULL) @@ -80,7 +83,8 @@ def main(): offered = orchard_files(args) snapshot.command(args.orchard, 'orchard.image.root_image', args.output / 'rootfs.tar.zst', '--output', args.output) root = json.loads((args.output / 'root-image.json').read_text()) - shutil.copyfile(args.logo, args.output / 'logo.bin') + if args.architecture == 'aarch64': # a 32-bit release has no logo + shutil.copyfile(args.logo, args.output / 'logo.bin') published = write_manifests(args.output, args.release, root, offered, revision, architecture=args.architecture) # Assembly provenance is separate from the public installation contract. receipt = {'schema': 2, 'release': args.release, 'architecture': args.architecture, 'orchard': revision, diff --git a/release/manifests.py b/release/manifests.py index 40a6875..445ef07 100644 --- a/release/manifests.py +++ b/release/manifests.py @@ -8,10 +8,11 @@ # Per root architecture: the index orchard.image.platforms_index writes (arm64: platforms.toml; the 32-bit # platforms have their own), and how the release's boards start when the index doesn't say. +# Per root architecture: orchard's name for it, its index and the manifest's boot method +# (orchard docs/distribution-contract.md, "32-bit roots"). INDEXES = {'aarch64': 'platforms.toml', 'armv7h': 'platforms-armv7.toml'} -BOOT_METHODS = {'aarch64': 'checkm8-pongo', 'armv7h': 'cascadia'} -# Tables of the manifest that are not one of orchard's loaders. -NOT_LOADERS = ('orchard', 'install', 'root', 'logo', 'install_tools', 'platforms') +ORCHARD_ARCHITECTURES = {'aarch64': 'arm64', 'armv7h': 'arm'} +BOOT_METHODS = {'aarch64': 'checkm8-pongo', 'armv7h': 'recovery-autogo'} def sha(path): @@ -38,7 +39,7 @@ def write(output, version, root, offered, orchard, *, architecture='aarch64'): ({'commit', 'modified'}). """ validate(architecture) - boot_method = offered.get('boot_method', BOOT_METHODS[architecture]) + boot_method = BOOT_METHODS[architecture] document = ('schema = 2\nkind = "orchard.install"\ndistro = "omarchy-a"\n' 'name = "Omarchy A"\nversion = ' + json.dumps(version) + '\n' 'boot_method = ' + json.dumps(boot_method) + '\nmin_orchard = "0.2.0"\nprotocol = 1\n') @@ -52,10 +53,14 @@ def write(output, version, root, offered, orchard, *, architecture='aarch64'): 'passwordless_sudo': False}) document += table('root', {key: value for key, value in root.items() if key != 'map'}) document += table('root.map', root['map']) - document += table('logo', receipt(output / 'logo.bin')) - # orchard's loaders as its index names them (arm64: pongo and m1n1). - for name, row in offered['loaders'].items(): - document += table(name, row) + if architecture == 'aarch64': + document += table('logo', receipt(output / 'logo.bin')) + document += table('pongo', offered['loaders']['pongo']) + document += table('m1n1', offered['loaders']['m1n1']) + else: + # A 32-bit release names each board's Cascadia loader; it has no Pongo, m1n1 or logo. + for board, row in sorted(offered['loaders']['cascadia'].items()): + document += table(f'cascadia.{board}', row) document += table('install_tools', offered['install_tools']) for name, layer in sorted(offered['platforms'].items()): document += f'\n[platforms.{name}]\nboards = {json.dumps(layer["boards"])}\n' @@ -74,8 +79,10 @@ def read_release(manifest): value = tomllib.loads(Path(manifest).read_text()) if value.get('schema') != 2 or value.get('kind') != 'orchard.install' or value.get('distro') != 'omarchy-a': raise ValueError('Expected an Omarchy Orchard installation manifest') - loaders = {key: row for key, row in value.items() if isinstance(row, dict) and key not in NOT_LOADERS} + # The files that start the release: Pongo and m1n1, or each board's Cascadia loader. + loaders = ({f'cascadia {board}': row for board, row in value['cascadia'].items()} if 'cascadia' in value else + {name: value[name] for name in ('pongo', 'm1n1') if name in value}) return {'schema': value['schema'], 'protocol': value['protocol'], 'release': value['version'], 'boot_method': value['boot_method'], 'root': value['root'], 'loaders': loaders, 'pongo': value.get('pongo'), 'm1n1': value.get('m1n1'), - 'install_tools': value['install_tools'], 'logo': value['logo'], 'platforms': value['platforms']} + 'install_tools': value['install_tools'], 'logo': value.get('logo'), 'platforms': value['platforms']} diff --git a/tests/test_release_architecture.py b/tests/test_release_architecture.py index 86a86ac..1b461ee 100644 --- a/tests/test_release_architecture.py +++ b/tests/test_release_architecture.py @@ -89,9 +89,10 @@ def test_armv7_manifest_names_the_32_bit_loaders_without_pongo(self): path = manifests.write(root, 'armv7-test', block, offered, {'commit': 'a' * 40, 'modified': False}, architecture='armv7h') record = manifests.read_release(path) - self.assertEqual(record['boot_method'], 'cascadia') - self.assertEqual(sorted(record['loaders']), ['cascadia']) + self.assertEqual(record['boot_method'], 'recovery-autogo') + self.assertEqual(sorted(record['loaders']), ['cascadia p105']) self.assertIsNone(record['pongo']) + self.assertIsNone(record['logo']) if __name__ == '__main__': diff --git a/tests/test_release_contracts.py b/tests/test_release_contracts.py index b108fc4..88f8921 100644 --- a/tests/test_release_contracts.py +++ b/tests/test_release_contracts.py @@ -23,9 +23,10 @@ def armv7_files(output): """What orchard's 32-bit index offers, in its shape as assumed here: loaders, install tools, platforms.""" root, _ = release_files(output) - for name in ('cascadia.bin', 'install-tools-armv7.tar.gz', 'platform-apple-armv7.tar.gz'): + for name in ('cascadia-p105.bin', 'install-tools-armv7.tar.gz', 'platform-apple-armv7.tar.gz'): (output / name).write_bytes(name.encode()) - offered = {'loaders': {'cascadia': manifests.receipt(output / 'cascadia.bin')}, + # orchard's platforms-armv7.toml: a Cascadia loader per board. + offered = {'architecture': 'arm', 'loaders': {'cascadia': {'p105': manifests.receipt(output / 'cascadia-p105.bin')}}, 'install_tools': manifests.receipt(output / 'install-tools-armv7.tar.gz'), 'platforms': {'apple-armv7': {**manifests.receipt(output / 'platform-apple-armv7.tar.gz'), 'compression': 'gzip', 'expanded_bytes': 10, 'members': 2, @@ -76,7 +77,11 @@ def producer(snapshot, module, *argv, **kwargs): events.append(module) if module == 'orchard.image.platforms_index': _, offered = (release_files if architecture == 'aarch64' else armv7_files)(output) - text = ''.join(manifests.table('loaders.' + name, row) for name, row in offered['loaders'].items()) + loaders = offered['loaders'] + text = ''.join(manifests.table('loaders.cascadia.' + board, row) + for board, row in loaders.get('cascadia', {}).items()) + text += ''.join(manifests.table('loaders.' + name, row) + for name, row in loaders.items() if name != 'cascadia') text += manifests.table('install_tools', offered['install_tools']) text += ''.join(manifests.table('platforms.' + name, row) for name, row in offered['platforms'].items()) @@ -111,11 +116,13 @@ def test_armv7_is_a_block_image_release_from_the_32_bit_index(self): events, commands, document, receipt = self.release('armv7h', '--orchard-release', 'nightly-test', '--platform', 'apple-armv7') self.assertEqual(events, [('root', 'armv7h'), 'orchard.image.platforms_index', 'orchard.image.root_image']) - self.assertEqual(commands[0][1][1:], ('--release', 'nightly-test', '--platform', 'apple-armv7')) + self.assertEqual(commands[0][1][1:], ('--release', 'nightly-test', '--architecture', 'arm', + '--platform', 'apple-armv7')) self.assertEqual(document['root']['format'], 'blocks') - self.assertEqual(document['boot_method'], 'cascadia') - self.assertEqual(document['cascadia']['file'], 'cascadia.bin') - self.assertNotIn('pongo', document) + self.assertEqual(document['boot_method'], 'recovery-autogo') + self.assertEqual(document['cascadia']['p105']['file'], 'cascadia-p105.bin') + for absent in ('pongo', 'm1n1', 'logo'): + self.assertNotIn(absent, document) self.assertEqual(document['platforms']['apple-armv7']['boards'], ['p105']) self.assertNotIn('boot', document['platforms']['apple-armv7'], 'no recovery') self.assertEqual(receipt['architecture'], 'armv7h')