Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions armv7/Containerfile
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions armv7/README.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions armv7/build-base.py
Original file line number Diff line number Diff line change
@@ -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()
116 changes: 116 additions & 0 deletions tests/test_armv7_base.py
Original file line number Diff line number Diff line change
@@ -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()