diff --git a/armv7/Containerfile b/armv7/Containerfile new file mode 100644 index 0000000..41c94b1 --- /dev/null +++ b/armv7/Containerfile @@ -0,0 +1,11 @@ +# Native ARM64 apk verifies ARMv7 packages with the official ARMv7 signing keys. +FROM docker.io/library/alpine@sha256:6db0aa37b0f1ba86c31102b0972cb082a1d22ed1390440de84f1076d78d4d399 AS armkeys +FROM docker.io/library/alpine@sha256:e311ae748be7f56ae5cba304bc488f0786df68ee507513f654a5cc8597cffb42 +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 \ + && mkdir -p /armroot/bin \ + && cp /etc/apk/repositories /armroot/etc/apk/repositories \ + && cp -a /etc/apk/keys /armroot/etc/apk/keys diff --git a/armv7/README.md b/armv7/README.md new file mode 100644 index 0000000..fe7ad95 --- /dev/null +++ b/armv7/README.md @@ -0,0 +1,19 @@ +# ARMv7 recovery base + +Apple's 32-bit boards (the apple-armv7 platform, first the original iPad mini +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 +``` + +Pass `--no-cache` to refresh the package layer. Each build uses its returned +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 +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 new file mode 100644 index 0000000..08bed6f --- /dev/null +++ b/armv7/build-base.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Export signed Alpine ARMv7 packages for the 32-bit (apple-armv7) RAM recovery.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + 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') + 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) + recipe = Path(__file__).with_name('Containerfile') + with tempfile.TemporaryDirectory(prefix='armv7-recovery-base-') as temporary: + image_file = Path(temporary)/'image.id' + command = ['podman', 'build', '--iidfile', str(image_file)] + if args.no_cache: + command.append('--no-cache') + subprocess.run([*command, '-f', str(recipe), str(recipe.parent)], check=True) + image = image_file.read_text().strip() + if not re.fullmatch(r'(?:sha256:)?[0-9a-f]{64}', image): + parser.error('Podman returned an invalid base image ID') + 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: + subprocess.run(['podman', 'run', '--rm', '--network', 'none', image, + 'tar', '-C', '/armroot', '-cf', '-', '.'], stdout=stream, check=True) + with receipt.open('x') as stream: + stream.write(json.dumps({'schema': 1, 'architecture': 'armv7', + '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') + + +if __name__ == '__main__': + main() diff --git a/tests/test_armv7_base.py b/tests/test_armv7_base.py new file mode 100644 index 0000000..f380765 --- /dev/null +++ b/tests/test_armv7_base.py @@ -0,0 +1,116 @@ +"""Host-only checks for the signed ARMv7 recovery base exporter.""" +import hashlib +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +PROJECT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('armv7_base_test', PROJECT / 'armv7/build-base.py') +base = importlib.util.module_from_spec(spec) +spec.loader.exec_module(base) + + +class Armv7Base(unittest.TestCase): + def test_export_receipt_binds_image_recipe_and_stream(self): + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / 'base.tar' + image = 'sha256:' + 'a' * 64 + commands = [] + def run(command, **kwargs): + commands.append(command) + if command[1] == 'build': + Path(command[command.index('--iidfile') + 1]).write_text(image + '\n') + elif command[1] == 'run': + kwargs['stdout'].write(b'fixture base') + with patch('sys.argv', ['build-base', '--output', str(output)]), \ + 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()) + self.assertEqual(receipt['image'], image) + self.assertEqual(receipt['architecture'], 'armv7') + 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()) + self.assertEqual([command[1] for command in commands], ['build', 'run']) + self.assertNotIn('-t', commands[0]) + self.assertNotIn('--tag', commands[0]) + self.assertNotIn('--no-cache', commands[0]) + self.assertEqual(commands[1][commands[1].index('none') + 1], image) + self.assertIn('--network', commands[1]) + self.assertIn('none', commands[1]) + + def test_no_cache_uses_image_id_and_leaves_existing_tags_untouched(self): + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / 'base.tar' + image = 'sha256:' + 'a' * 64 + commands = [] + def run(command, **kwargs): + commands.append(command) + if command[1] == 'build': + Path(command[command.index('--iidfile') + 1]).write_text(image + '\n') + elif command[1] == 'run': + kwargs['stdout'].write(b'fixture base') + with patch('sys.argv', ['build-base', '--output', str(output), '--no-cache']), \ + patch.object(base.subprocess, 'run', side_effect=run), \ + patch.object(base.subprocess, 'check_output', return_value=image.removeprefix('sha256:') + '\n') as inspect: + base.main() + self.assertIn('--no-cache', commands[0]) + 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) + + def test_mismatched_inspection_is_rejected_before_export(self): + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / 'base.tar' + image = 'sha256:' + 'a' * 64 + def build(command, **_kwargs): + Path(command[command.index('--iidfile') + 1]).write_text(image + '\n') + with patch('sys.argv', ['build-base', '--output', str(output)]), \ + patch.object(base.subprocess, 'run', side_effect=build) as run, \ + patch.object(base.subprocess, 'check_output', return_value='b' * 64 + '\n'): + with self.assertRaises(SystemExit): + base.main() + self.assertEqual(run.call_count, 1) + self.assertFalse(output.exists()) + self.assertFalse(output.with_suffix('.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')): + with self.subTest(present=present): + present.write_bytes(b'existing') + with patch('sys.argv', ['build-base', '--output', str(output)]), \ + patch.object(base.subprocess, 'run') as run: + with self.assertRaises(SystemExit): + base.main() + run.assert_not_called() + self.assertEqual(present.read_bytes(), b'existing') + present.unlink() + receipt = output.with_suffix('.json') + receipt.symlink_to(Path(temporary) / 'missing') + with patch('sys.argv', ['build-base', '--output', str(output)]), \ + patch.object(base.subprocess, 'run') as run: + with self.assertRaises(SystemExit): + base.main() + run.assert_not_called() + self.assertTrue(receipt.is_symlink()) + + def test_recipe_pins_images_and_uses_verified_armv7_packages(self): + recipe = (PROJECT / 'armv7/Containerfile').read_text() + images = [line for line in recipe.splitlines() if line.startswith('FROM ')] + self.assertEqual(len(images), 2) + self.assertTrue(all('@sha256:' in line for line in images)) + self.assertIn('--arch armv7', recipe) + self.assertIn('--no-scripts', recipe) + self.assertIn('/etc/apk/keys', recipe) + + +if __name__ == '__main__': + unittest.main()