From 2f6d4653ac4cfff97e191f39fa5c88c81d80aa11 Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:00:25 -0600 Subject: [PATCH 1/3] build: consume architecture-aware Orchard producer records Resolve the shared loader and recovery tools from the selected platform records. Verify that every selected platform matches the Alpine release root before building or pinning components. Refuse mixed architectures or inconsistent tools while retaining compatibility with previous ARM64 snapshot documents. The standalone ARMv7 recovery-base producer stays available through armv7/build-base.py. This accompanies Orchard's ARMv7 producer-contract change. The complete Minimal Linux test suite passes (60 tests) against that Orchard snapshot. Assisted-by: LLM --- build.py | 6 ++++ producer_inputs.py | 31 ++++++++++++++++++++ tests/test_producer_inputs.py | 54 +++++++++++++++++++++++++++++++++++ tools/build.py | 14 +++++---- 4 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 producer_inputs.py create mode 100644 tests/test_producer_inputs.py diff --git a/build.py b/build.py index a17226a..4806cb3 100644 --- a/build.py +++ b/build.py @@ -201,6 +201,12 @@ def main(): components, inputs = verify_inputs(args.inputs) resolved = json.loads(args.orchard_inputs.read_text()) if args.orchard_inputs else None revision = resolved['orchard'] if resolved else None + if resolved is not None: + try: + from producer_inputs import components as producer_components + producer_components(resolved) + except ValueError as error: + parser.error(str(error)) args.output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.minimal-build-', dir=args.output.parent) as temp: output = Path(temp) / 'release' diff --git a/producer_inputs.py b/producer_inputs.py new file mode 100644 index 0000000..28321be --- /dev/null +++ b/producer_inputs.py @@ -0,0 +1,31 @@ +"""Validate Orchard producer records against the shared Alpine release root.""" +from pathlib import Path + +TOOLS = ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw') + + +def components(document): + """Return one coherent m1n1/tool set for the existing ARM64 release.""" + if document.get('architecture', 'arm64') != 'arm64': + raise ValueError('The release needs an ARM64 base; build ARMv7 recovery bases with armv7/build-base.py') + platforms = document.get('platforms', {}) + if not platforms: + raise ValueError('Orchard inputs name no platforms') + shared = document.get('shared', {}) + records = [] + for name, row in platforms.items(): + if row.get('architecture', 'arm64') != 'arm64' or row.get('image', 'Image.gz') != 'Image.gz': + raise ValueError('Platform ' + name + ' does not match the ARM64 release root') + loader = row.get('loader', {'kind': 'm1n1', 'file': shared.get('m1n1')}) + if loader.get('kind') != 'm1n1' or not loader.get('file'): + raise ValueError('Platform ' + name + ' needs an m1n1 loader') + tools = row.get('tools', shared.get('tools', {})) + if any(not isinstance(tools.get(key), str) or not tools[key] for key in TOOLS): + raise ValueError('Platform ' + name + ' has incomplete static tools') + records.append((str(Path(loader['file'])), {key: str(Path(tools[key])) for key in TOOLS})) + if any(record != records[0] for record in records[1:]): + raise ValueError('Platforms in one release need the same loader and static tools') + if not isinstance(shared.get('pongo'), str) or not shared['pongo']: + raise ValueError('The ARM64 release needs its Pongo input') + loader, tools = records[0] + return {'m1n1': loader, 'pongo': shared['pongo'], 'tools': tools} diff --git a/tests/test_producer_inputs.py b/tests/test_producer_inputs.py new file mode 100644 index 0000000..fbe12fe --- /dev/null +++ b/tests/test_producer_inputs.py @@ -0,0 +1,54 @@ +"""The release consumes architecture-aware producer records as one root.""" +import unittest +import producer_inputs + + +def document(): + tools = {name: '/cache/tools/' + name for name in producer_inputs.TOOLS} + return {'architecture': 'arm64', 'shared': {'pongo': '/cache/pongo'}, + 'platforms': {name: {'architecture': 'arm64', 'image': 'Image.gz', + 'loader': {'kind': 'm1n1', 'file': '/cache/m1n1'}, 'tools': dict(tools)} + for name in ('apple-4k', 'apple-16k')}} + + +class ProducerInputsTests(unittest.TestCase): + def test_current_records_resolve_shared_components(self): + result = producer_inputs.components(document()) + self.assertEqual(result['m1n1'], '/cache/m1n1') + self.assertEqual(result['pongo'], '/cache/pongo') + self.assertEqual(result['tools']['busybox'], '/cache/tools/busybox') + + def test_mixed_architectures_fail_before_root_assembly(self): + data = document() + data['platforms']['apple-4k']['architecture'] = 'arm' + with self.assertRaisesRegex(ValueError, 'ARM64 release root'): + producer_inputs.components(data) + + def test_armv7_release_requires_its_own_root_path(self): + data = document() + data['architecture'] = 'arm' + with self.assertRaisesRegex(ValueError, 'armv7/build-base.py'): + producer_inputs.components(data) + + def test_different_tools_fail_before_component_pinning(self): + data = document() + data['platforms']['apple-4k']['tools']['busybox'] = '/other/busybox' + with self.assertRaisesRegex(ValueError, 'same loader and static tools'): + producer_inputs.components(data) + + def test_wrong_loader_and_incomplete_tools_are_rejected(self): + for field, value in [('loader', {'kind': 'cascadia', 'file': '/cache/cascadia'}), ('tools', {})]: + data = document() + data['platforms']['apple-4k'][field] = value + with self.assertRaises(ValueError): + producer_inputs.components(data) + + def test_older_snapshot_components_still_resolve(self): + data = document() + row = data['platforms']['apple-4k'] + data['shared'].update(m1n1=row['loader']['file'], tools=row['tools']) + data.pop('architecture') + for row in data['platforms'].values(): + for key in ('architecture', 'image', 'loader', 'tools'): + row.pop(key) + self.assertEqual(producer_inputs.components(data)['m1n1'], '/cache/m1n1') diff --git a/tools/build.py b/tools/build.py index da0ff3d..e8c9a3c 100755 --- a/tools/build.py +++ b/tools/build.py @@ -18,6 +18,7 @@ PROJECT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT)) +import producer_inputs # noqa: E402 import snapshot # noqa: E402 orchard's snapshot and commands; none of its Python # What each component is made from, relative to this checkout. INPUTS = { @@ -45,11 +46,6 @@ def main(): # The release builder builds every platform when it is given none. selected = [part for name in args.platform if name != 'all' for part in ('--platform', name)] - base = snapshot.component('base', INPUTS['base'], - lambda path, work: python('build_base.py', '--output', path), artifact='base.tar.gz') - artwork = snapshot.component('artwork', INPUTS['artwork'], - lambda path, work: python('boot/build-artwork.py', '--output', path), - artifact='artwork') (PROJECT / 'out').mkdir(exist_ok=True) # What orchard supplies finished: kernels, boards, m1n1, Pongo, tools (orchard.inputs). Kept beside the # release, not in it (its paths are this host's): under "produced" it says how orchard got each one. @@ -57,7 +53,13 @@ def main(): resolved.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.inputs-', dir=PROJECT / 'out') as temporary: snapshot.command(orchard, 'inputs', '--output', resolved, *selected) - shared = json.loads(resolved.read_text())['shared'] + shared = producer_inputs.components(json.loads(resolved.read_text())) + base = snapshot.component('base', INPUTS['base'], + lambda path, work: python('build_base.py', '--output', path), artifact='base.tar.gz') + artwork = snapshot.component('artwork', INPUTS['artwork'], + lambda path, work: python('boot/build-artwork.py', '--output', path), + artifact='artwork') + tools = {name: Path(path) for name, path in shared['tools'].items()} inputs = Path(temporary) / 'public' python('pin_components.py', '--loader', shared['m1n1'], '--splash', artwork / 'splash', From 4b470428b6c56a6f66a87fd713fa21a41824ec0e Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:28:11 -0600 Subject: [PATCH 2/3] armv7: export a recovery-compatible signed package base Orchard stages recovery from a gzip tar with a merged /usr tree and an arm architecture receipt. Export that contract from the signed ARMv7 base, include the native storage tools and mke2fs configuration, and check the expected paths before exporting. Assisted-by: LLM --- armv7/Containerfile | 18 ++++++++++++++++-- armv7/README.md | 10 +++++++--- armv7/build-base.py | 14 +++++++++++--- tests/test_armv7_base.py | 24 +++++++++++++++++------- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/armv7/Containerfile b/armv7/Containerfile index 41c94b1..4efb80c 100644 --- a/armv7/Containerfile +++ b/armv7/Containerfile @@ -5,7 +5,21 @@ COPY --from=armkeys /etc/apk/keys /armroot/etc/apk/keys COPY --from=armkeys /etc/apk/keys /etc/apk/keys RUN apk --root /armroot --arch armv7 --initdb --no-scripts \ --keys-dir /etc/apk/keys --repositories-file /etc/apk/repositories \ - add busybox musl python3 dropbear nfs-utils \ + add busybox musl python3 dropbear nfs-utils util-linux e2fsprogs \ && mkdir -p /armroot/bin \ && cp /etc/apk/repositories /armroot/etc/apk/repositories \ - && cp -a /etc/apk/keys /armroot/etc/apk/keys + && cp -a /etc/apk/keys /armroot/etc/apk/keys \ + && for directory in bin sbin lib; do \ + mkdir -p "/armroot/usr/$directory"; \ + cp -a "/armroot/$directory/." "/armroot/usr/$directory/"; \ + rm -rf "/armroot/$directory"; \ + ln -s "usr/$directory" "/armroot/$directory"; \ + done \ + && test -f /armroot/usr/bin/python3 \ + && test -f /armroot/usr/sbin/blkid \ + && test -f /armroot/usr/sbin/sfdisk \ + && test -f /armroot/usr/sbin/mke2fs \ + && test -f /armroot/etc/mke2fs.conf \ + && test "$(readlink /armroot/lib)" = usr/lib \ + && test "$(readlink /armroot/bin)" = usr/bin \ + && test "$(readlink /armroot/sbin)" = usr/sbin diff --git a/armv7/README.md b/armv7/README.md index fe7ad95..d4a9cc2 100644 --- a/armv7/README.md +++ b/armv7/README.md @@ -5,7 +5,7 @@ Wi-Fi) use an ARMv7 recovery environment. Build its Alpine package base in an ARM64 Podman environment: ```sh -python3 armv7/build-base.py --output /absolute/new/alpine-base.tar +python3 armv7/build-base.py --output /absolute/new/alpine-base.tar.gz ``` Pass `--no-cache` to refresh the package layer. Each build uses its returned @@ -13,7 +13,11 @@ image ID, leaving existing Podman image tags untouched. The recipe uses pinned container images and the official ARMv7 Alpine signing keys. It extracts signed ARMv7 packages without running their scripts. A JSON -receipt beside the archive records the target architecture, image identity, -recipe hash and archive hash. The base does not contain owner firmware or +receipt at `alpine-base.tar.gz.json` records Orchard's target architecture +(`arm`), image identity, recipe hash and archive hash. The gzip tar uses the +merged `/usr` layout expected by Orchard's recovery producer and includes +native `blkid`, `sfdisk`, `mke2fs` and its configuration. Python's musl loader +and libraries live under `/usr/lib` so the producer can stage their closure. +The base does not contain owner firmware or calibration. A separate private recovery composer and the companion hardware and CLI contributions are required for a tethered boot. diff --git a/armv7/build-base.py b/armv7/build-base.py index 08bed6f..76f4fc7 100644 --- a/armv7/build-base.py +++ b/armv7/build-base.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Export signed Alpine ARMv7 packages for the 32-bit (apple-armv7) RAM recovery.""" import argparse +import gzip import hashlib import json from pathlib import Path import re +import shutil import subprocess import tempfile @@ -15,7 +17,7 @@ def main(): parser.add_argument('--no-cache', action='store_true', help='refresh the package build without cached layers') args = parser.parse_args() output = args.output.absolute() - receipt = output.with_suffix('.json') + receipt = output.with_suffix(output.suffix + '.json') if output.exists() or output.is_symlink() or receipt.exists() or receipt.is_symlink(): parser.error('Choose a fresh base output') output.parent.mkdir(parents=True, exist_ok=True) @@ -32,11 +34,17 @@ def main(): observed = subprocess.check_output(['podman', 'image', 'inspect', image, '--format', '{{.Id}}'], text=True).strip() if observed.removeprefix('sha256:') != image.removeprefix('sha256:'): parser.error('Podman image inspection differs from the build output') - with output.open('xb') as stream: + archive = Path(temporary)/'base.tar' + with archive.open('xb') as stream: subprocess.run(['podman', 'run', '--rm', '--network', 'none', image, 'tar', '-C', '/armroot', '-cf', '-', '.'], stdout=stream, check=True) + with archive.open('rb') as source, output.open('xb') as stream: + with gzip.GzipFile(fileobj=stream, filename='', mode='wb', mtime=0, + compresslevel=1) as compressed: + shutil.copyfileobj(source, compressed) with receipt.open('x') as stream: - stream.write(json.dumps({'schema': 1, 'architecture': 'armv7', + stream.write(json.dumps({'schema': 1, 'architecture': 'arm', + 'archive': 'tar+gzip', 'layout': 'merged-usr', 'signed_apk': True, 'image': image, 'bytes': output.stat().st_size, 'sha256': hashlib.sha256(output.read_bytes()).hexdigest(), 'recipe_sha256': hashlib.sha256(recipe.read_bytes()).hexdigest()}, indent=2)+'\n') diff --git a/tests/test_armv7_base.py b/tests/test_armv7_base.py index f380765..8aac348 100644 --- a/tests/test_armv7_base.py +++ b/tests/test_armv7_base.py @@ -1,5 +1,6 @@ """Host-only checks for the signed ARMv7 recovery base exporter.""" import hashlib +import gzip import importlib.util import json from pathlib import Path @@ -17,7 +18,7 @@ class Armv7Base(unittest.TestCase): def test_export_receipt_binds_image_recipe_and_stream(self): with tempfile.TemporaryDirectory() as temporary: - output = Path(temporary) / 'base.tar' + output = Path(temporary) / 'base.tar.gz' image = 'sha256:' + 'a' * 64 commands = [] def run(command, **kwargs): @@ -30,9 +31,13 @@ def run(command, **kwargs): patch.object(base.subprocess, 'run', side_effect=run), \ patch.object(base.subprocess, 'check_output', return_value=image + '\n'): base.main() - receipt = json.loads(output.with_suffix('.json').read_text()) + receipt = json.loads(output.with_suffix('.gz.json').read_text()) self.assertEqual(receipt['image'], image) - self.assertEqual(receipt['architecture'], 'armv7') + self.assertEqual(receipt['architecture'], 'arm') + self.assertEqual(receipt['archive'], 'tar+gzip') + self.assertEqual(receipt['layout'], 'merged-usr') + self.assertEqual(gzip.decompress(output.read_bytes()), b'fixture base') + self.assertEqual(output.read_bytes()[4:8], b'\0\0\0\0') self.assertTrue(receipt['signed_apk']) self.assertEqual(receipt['sha256'], hashlib.sha256(output.read_bytes()).hexdigest()) self.assertEqual(receipt['recipe_sha256'], hashlib.sha256((PROJECT / 'armv7/Containerfile').read_bytes()).hexdigest()) @@ -63,7 +68,7 @@ def run(command, **kwargs): self.assertNotIn('-t', commands[0]) self.assertNotIn('--tag', commands[0]) self.assertEqual(inspect.call_args.args[0][3], image) - self.assertEqual(json.loads(output.with_suffix('.json').read_text())['image'], image) + self.assertEqual(json.loads(output.with_suffix('.tar.json').read_text())['image'], image) def test_mismatched_inspection_is_rejected_before_export(self): with tempfile.TemporaryDirectory() as temporary: @@ -78,12 +83,12 @@ def build(command, **_kwargs): base.main() self.assertEqual(run.call_count, 1) self.assertFalse(output.exists()) - self.assertFalse(output.with_suffix('.json').exists()) + self.assertFalse(output.with_suffix('.tar.json').exists()) def test_existing_output_or_receipt_stops_before_build(self): with tempfile.TemporaryDirectory() as temporary: output = Path(temporary) / 'base.tar' - for present in (output, output.with_suffix('.json')): + for present in (output, output.with_suffix('.tar.json')): with self.subTest(present=present): present.write_bytes(b'existing') with patch('sys.argv', ['build-base', '--output', str(output)]), \ @@ -93,7 +98,7 @@ def test_existing_output_or_receipt_stops_before_build(self): run.assert_not_called() self.assertEqual(present.read_bytes(), b'existing') present.unlink() - receipt = output.with_suffix('.json') + receipt = output.with_suffix('.tar.json') receipt.symlink_to(Path(temporary) / 'missing') with patch('sys.argv', ['build-base', '--output', str(output)]), \ patch.object(base.subprocess, 'run') as run: @@ -110,6 +115,11 @@ def test_recipe_pins_images_and_uses_verified_armv7_packages(self): self.assertIn('--arch armv7', recipe) self.assertIn('--no-scripts', recipe) self.assertIn('/etc/apk/keys', recipe) + self.assertIn('util-linux e2fsprogs', recipe) + self.assertIn('for directory in bin sbin lib', recipe) + for name in ('blkid', 'sfdisk', 'mke2fs'): + self.assertIn('test -f /armroot/usr/sbin/' + name, recipe) + self.assertIn('test -f /armroot/etc/mke2fs.conf', recipe) if __name__ == '__main__': From 93aba48eb951a7672dcb9b3e49cd816d8709045b Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:36:47 -0600 Subject: [PATCH 3/3] build: validate selected backend inputs before root assembly Check optional root_format and tools.lwvm records before composing the shared ARM64 root. Require a static mapper with a file-backed executable entry and coherent rescue tools, then pass the selected document to Orchard's platform index. Keep the upstream block-root and index flow. Project each layer receipt onto the install manifest's allowed fields, while retaining its architecture in the private build receipt. This keeps the real platform producer's architecture metadata from failing schema validation during release assembly. All 67 Minimal Linux tests pass. A source-pinned integration fixture runs Orchard's actual index and layer producers, checks selected board, loader and tool bytes, and validates the resulting manifest and local receipts. Orchard's installed-root companion supplies selected index inputs. Assisted-by: LLM --- BUILD.md | 14 +++- build.py | 4 +- producer_inputs.py | 69 ++++++++++++++++++++ tests/test_producer_inputs.py | 104 ++++++++++++++++++++++++++++++ tests/test_publication.py | 4 +- tests/test_release_inputs.py | 118 ++++++++++++++++++++++++++++++++++ 6 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 tests/test_release_inputs.py diff --git a/BUILD.md b/BUILD.md index 1421e80..12f4d92 100644 --- a/BUILD.md +++ b/BUILD.md @@ -75,7 +75,19 @@ signature or trust service. Use fresh output paths; producers refuse overwriting. The official Alpine OCI digest is pinned in `system/Containerfile`; APK repositories retain signature checking. The base records package versions in `/etc/alpine-packages.lock`. Its archive hash and all selected hardware hashes are recorded separately from the install contract. The APK mirror is not an immutable snapshot: future base rebuilds may select newer signed package revisions and must get new receipts. -The producer composes Alpine-owned policy into one public gzip root for every platform (no kernel or modules: orchard's platform layer brings those at install, and orchard writes the boot entry), seals a recovery per platform to the root's receipt, adds orchard's layer per platform, and emits `release.toml` plus the index that pins it. Public build receipts omit local machine paths. No private firmware, SSH keys, ADT or machine IDs are embedded. +The producer composes Alpine-owned policy into one public root archive, converts it with `orchard.image.root_image`, and takes platform layers, loaders and install tools from `orchard.image.platforms_index`. It emits `release.toml` plus the index that pins it. Public build receipts omit local machine paths. No private firmware, SSH keys, ADT or machine IDs are embedded. + +Builds using `--orchard-inputs` validate every selected platform against the +ARM64 release root before root assembly. The standalone signed ARMv7 recovery +base remains available through `armv7/build-base.py`. + +LwVM records require a static mapper for the selected ISA and a coherent rescue +tool pair. This build passes the validated input document to +`orchard.image.platforms_index --inputs`; Orchard's installed-root companion +consumes the selected mapper and rescue paths when creating the layer. A local +integration fixture checks the selected ARM64 board, loader and tool bytes +through the real index and validates the resulting manifest. Physical P105 +qualification remains part of the ARMv7 device work. ## Native isolated checks diff --git a/build.py b/build.py index 4806cb3..11176b7 100644 --- a/build.py +++ b/build.py @@ -177,7 +177,9 @@ def manifests(output, version, root, offered, orchard): 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' - 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 + ('file', 'url', 'bytes', 'sha256', 'compression', 'expanded_bytes', 'members', 'kernel_release') + if key in layer}) path = output / 'release.toml' path.write_text(document) (output / 'index.toml').write_text('schema = 2\nkind = "orchard.index"\ndistro = "minimal-linux"\n' diff --git a/producer_inputs.py b/producer_inputs.py index 28321be..b4facad 100644 --- a/producer_inputs.py +++ b/producer_inputs.py @@ -1,9 +1,77 @@ """Validate Orchard producer records against the shared Alpine release root.""" from pathlib import Path +import struct TOOLS = ('busybox', 'dropbear', 'dropbearkey', 'wpa_supplicant', 'wpa_cli', 'iw') +def lwvm_helper(row): + """Validate an optional mapper before starting the shared ARM64 root build.""" + root_format = row.get('root_format', 'gpt') + if root_format not in ('gpt', 'lwvm'): + raise ValueError('Unsupported platform root format') + value = row.get('tools', {}).get('lwvm') + if root_format == 'gpt': + if value is not None: + raise ValueError('A GPT platform cannot supply an LwVM mapper') + return None + if not isinstance(value, str) or not value or not Path(value).is_absolute(): + raise ValueError('An LwVM platform needs an absolute mapper path') + path = Path(value) + if path.is_symlink() or not path.is_file(): + raise ValueError('The LwVM mapper must be a regular file') + data = path.read_bytes() + if len(data) < 64 or data[:7] != b'\x7fELF\x02\x01\x01' or struct.unpack_from(' len(data): + raise ValueError('The LwVM mapper has invalid ELF program headers') + entry = struct.unpack_from(' len(data): + raise ValueError('The LwVM mapper has a truncated ELF segment') + if kind == 1: + if memory < length: + raise ValueError('The LwVM mapper has an invalid loadable ELF segment') + loadable |= length > 0 + executable_entry |= bool(flags & 1 and virtual <= entry < virtual + length) + if kind == 3: + raise ValueError('The LwVM mapper must be static') + if kind == 2: + if length % 16: + raise ValueError('The LwVM mapper has an invalid dynamic table') + for position in range(start, start + length, 16): + tag, _ = struct.unpack_from('