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/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/build.py b/build.py index a17226a..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' @@ -201,6 +203,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..b4facad --- /dev/null +++ b/producer_inputs.py @@ -0,0 +1,100 @@ +"""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('