From 978542bc871f8140a205c8ff80965476e41c7c3c Mon Sep 17 00:00:00 2001 From: OverSu Date: Sat, 3 Oct 2026 22:33:47 +0200 Subject: [PATCH] Prepare verified Sargo packages and independent boot recovery --- changelog.json | 7 + tests/test_publication.py | 4 +- tools/prepare-publication.py | 4 +- tundra/tests/test_distribution_package.py | 110 +++++++++++++ tundra/tests/test_persistent_install.py | 2 +- tundra/tests/test_recovery_kit.py | 109 +++++++++++++ tundra/tests/test_system_image.py | 21 +++ tundra/tests/test_temporary_boot.py | 22 +++ tundra/tools/build-system-image.py | 22 ++- tundra/tools/export-sargo-recovery.py | 65 ++++++++ tundra/tools/install-sargo.py | 3 +- tundra/tools/prepare-sargo-package.py | 184 ++++++++++++++++++++++ tundra/tools/restore-sargo.py | 109 +++++++++++++ tundra/tools/test-sargo-boot.py | 11 +- 14 files changed, 665 insertions(+), 8 deletions(-) create mode 100644 tundra/tests/test_distribution_package.py create mode 100644 tundra/tests/test_recovery_kit.py create mode 100644 tundra/tools/export-sargo-recovery.py create mode 100644 tundra/tools/prepare-sargo-package.py create mode 100644 tundra/tools/restore-sargo.py diff --git a/changelog.json b/changelog.json index cde43ef7..f825efaa 100644 --- a/changelog.json +++ b/changelog.json @@ -5,6 +5,13 @@ "en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nOne Gaia base, three platforms: **desktop**, **Android (APK)** and **Tundra / Sargo**.\nThis journal covers visible changes and major technical milestones.\n\n**Status:** “In development” refers to local sources, “APK built” to a test package,\nand “Published” to a public repository release. Shared source changes reach installed\nAPKs and phone images only after those packages are updated." }, "entries": [ + { + "id": "2026-10-03-sargo-installation-tools", + "text": { + "fr": "## En développement — 3 octobre 2026 — Préparation et secours Sargo\n\n### Tundra · Sargo\n\n- Ajout d’un outil de préparation locale : contrôle du manifeste et de l’archive, décompression bornée, vérification ext4 et création d’un espace de données vierge propre à l’installation. Les dossiers existants ne sont pas écrasés.\n- Ajout d’un kit de restauration privé exportable hors du dépôt. Il conserve la sauvegarde originale de boot_a et un outil autonome qui vérifie le modèle, le numéro de série, le slot et les empreintes avant toute restauration explicite. Il ne constitue pas une sauvegarde des données utilisateur.\n- La restauration existante ne dépend plus de la présence de l’image de diagnostic. Les contrôles de la sauvegarde sont conservés.\n- Construction système : référence matérielle ARM64 désormais explicite, avec refus d’une référence limitée à l’affichage ou dépourvue des services requis.\n- Préparation de l’archive système réelle et vérification locale du kit de secours effectuées. Aucun nouveau flash ni nouvelle ROM publiée ; l’installation automatique sur un autre Sargo reste à finaliser.\n\n### Commun · PC / Android / Tundra\n\n- Le journal JSON partagé est conservé dans les futures publications de sources ; les documents de travail Markdown restent exclus.\n\n### PC / Android · APK\n\n- Aucune modification fonctionnelle ni nouvelle compilation dans cette étape.", + "en": "## In development — 3 October 2026 — Sargo preparation and recovery tools\n\n### Tundra · Sargo\n\n- Add local package preparation: verify manifest and archive checksums, bound decompression, check ext4 and create a fresh data image for each installation. Existing directories are never overwritten.\n- Add a private recovery kit that can be exported outside the checkout. It retains the original boot_a backup and a standalone tool that checks model, serial number, slot and checksums before explicit restoration. It is not a user-data backup.\n- Existing restoration no longer requires the diagnostic image to remain available. Backup validation is retained.\n- System builds now require an explicit ARM64 hardware reference and reject display-only references or missing required services.\n- Prepare the real system archive and verify the recovery kit locally. No new flash or public ROM release; automated installation on another Sargo still needs completion.\n\n### Shared · Desktop / Android / Tundra\n\n- Retain the shared JSON changelog in future source publications; internal Markdown working documents remain excluded.\n\n### Desktop / Android · APK\n\n- No functional changes or new build in this step." + } + }, { "id": "2026-10-03-sargo-modem-persistence", "text": { diff --git a/tests/test_publication.py b/tests/test_publication.py index c1b2ae86..efdfa54a 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -8,13 +8,13 @@ pub=importlib.util.module_from_spec(spec);spec.loader.exec_module(pub) class Publication(unittest.TestCase): def test_internal_documents_and_credentials_are_excluded(self): for name in ['docs/CHANTIER.md','docs/ROADMAP.json','tundra/docs/STATUS.md', - 'README-internal.md','gaia/README.md','changelog.json', + 'README-internal.md','gaia/README.md','CHANGELOG.md','CHANGELOG.en.md', 'tundra/out/example/client-key','tundra/logs/device/storage-layout.json', 'android/keys/preview.keystore','android/dist/preview.apk', 'profiles/example/cookies.sqlite','logs/publication/forgejo-token']: with self.subTest(name=name):self.assertIsNone(pub.target_name(name)) def test_sources_readmes_and_license_notices_survive(self): for name in ['README.md','README.fr.md','host/shell.js','tundra/boot/native-root.sh', - 'gaia/LICENSE','NOTICE','docs/media/vulpes-logo.png']: + 'gaia/LICENSE','NOTICE','changelog.json','docs/media/vulpes-logo.png']: self.assertEqual(pub.target_name(name),name) self.assertEqual(pub.target_name('gaia/vendor/LICENSE.md'),'gaia/vendor/LICENSE') diff --git a/tools/prepare-publication.py b/tools/prepare-publication.py index 1d2ad236..594abba2 100644 --- a/tools/prepare-publication.py +++ b/tools/prepare-publication.py @@ -12,8 +12,8 @@ ROOT = Path(__file__).resolve().parents[1] SOURCE_DIRS = {'adapters','android','assets','contracts','gaia','host','overrides','services','tests','tools','tundra'} ROOT_FILES = {'.gitignore','.nvmrc','.prettierignore','.prettierrc.json','LICENSE','NOTICE', 'README.md','README.fr.md','engine-lock.json','package.json','package-lock.json', - 'pyproject.toml','requirements-dev.txt','release.json','run.sh','setup.sh'} -INTERNAL = {'changelog.json','VALIDATION.json','candidate-engine.json', + 'pyproject.toml','requirements-dev.txt','release.json','changelog.json','run.sh','setup.sh'} +INTERNAL = {'VALIDATION.json','candidate-engine.json', 'tools/export-changelog.py','tools/sync-forgejo-changelog.py','tests/test_changelog.py', 'tools/check-checkout.py'} MEDIA = {'docs/media/vulpes-logo.png','docs/media/vulpes-desktop.png','docs/media/vulpes-gecko.png'} diff --git a/tundra/tests/test_distribution_package.py b/tundra/tests/test_distribution_package.py new file mode 100644 index 00000000..7ea747a1 --- /dev/null +++ b/tundra/tests/test_distribution_package.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: MPL-2.0 +import hashlib +import importlib.util +import json +import lzma +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +TOOLS = Path(__file__).resolve().parents[1]/'tools' +sys.path.insert(0, str(TOOLS)) +spec = importlib.util.spec_from_file_location('distribution', TOOLS/'prepare-sargo-package.py') +package = importlib.util.module_from_spec(spec) +spec.loader.exec_module(package) + + +class DistributionTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.content = b'Tundra'*4096 + bytes(8192) + self.archive = self.root/'system.ext4.xz' + self.archive.write_bytes(lzma.compress(self.content)) + self.manifest = self.root/'package.json' + self.value = {'device': 'sargo', 'version': 'test', 'systemImage': self.archive.name, + 'uncompressedBytes': len(self.content), + 'systemImageSha256': hashlib.sha256(self.content).hexdigest(), + 'filesystemCheckPassed': True, 'compressionCheckPassed': True} + self.save() + + def save(self): + self.value['archiveBytes'] = self.archive.stat().st_size + self.value['archiveSha256'] = package.digest(self.archive) + self.manifest.write_text(json.dumps(self.value)) + self.expected = package.digest(self.manifest) + + def test_manifest_and_expanded_image_verified(self): + value = package.checked_manifest(self.manifest, self.expected) + archive = package.checked_archive(self.root, value) + image = self.root/'expanded.ext4' + package.unpack(archive, image, value) + self.assertEqual(image.read_bytes(), self.content) + + def test_wrong_manifest_or_archive_hash_rejected(self): + with self.assertRaisesRegex(ValueError, 'manifest checksum'): + package.checked_manifest(self.manifest, '0'*64) + self.archive.write_bytes(b'bad') + with self.assertRaisesRegex(ValueError, 'checksum or size'): + package.checked_archive(self.root, self.value) + + def test_paths_links_and_wrong_device_rejected(self): + for name in ('../system.ext4.xz', '/tmp/system.ext4.xz', 'boot.img', 'sub/system.ext4.xz'): + self.value['systemImage'] = name + self.save() + with self.assertRaises(ValueError): + package.checked_manifest(self.manifest, self.expected) + self.value['systemImage'] = self.archive.name + self.value['device'] = 'bonito' + self.save() + with self.assertRaisesRegex(ValueError, 'Only Sargo'): + package.checked_manifest(self.manifest, self.expected) + original = self.archive.read_bytes() + self.archive.unlink() + other = self.root/'outside'; other.write_bytes(original) + self.archive.symlink_to(other) + with self.assertRaisesRegex(ValueError, 'regular archive'): + package.checked_archive(self.root, self.value) + + def test_truncated_and_concatenated_streams_rejected(self): + original = self.archive.read_bytes() + for data in (original[:-4], original+b'extra', original+original): + self.archive.write_bytes(data) + self.save() + target = self.root/'partial.ext4' + with self.assertRaises((ValueError, lzma.LZMAError)): + package.unpack(self.archive, target, self.value) + self.assertFalse(target.exists()) + + def test_expansion_limit_and_wrong_image_digest_leave_no_partial_image(self): + for key, value in (('uncompressedBytes', 4096), ('systemImageSha256', '0'*64)): + bad = dict(self.value, **{key: value}) + target = self.root/'partial.ext4' + with self.assertRaises(ValueError): + package.unpack(self.archive, target, bad) + self.assertFalse(target.exists()) + + def test_existing_image_and_data_are_never_overwritten(self): + target = self.root/'existing.ext4'; target.write_bytes(b'existing user data') + with self.assertRaises(FileExistsError): + package.unpack(self.archive, target, self.value) + self.assertEqual(target.read_bytes(), b'existing user data') + with patch.object(package.subprocess, 'run') as run: + with self.assertRaisesRegex(ValueError, 'already exists'): + package.prepare(self.manifest, self.expected, self.root, 1024) + run.assert_not_called() + + def test_verify_only_does_not_run_device_or_filesystem_commands(self): + with patch.object(sys, 'argv', ['prepare', '--manifest', str(self.manifest), + '--manifest-sha256', self.expected]), \ + patch.object(package.subprocess, 'run') as run, \ + patch.object(package.os, 'umask'): + package.main() + run.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tundra/tests/test_persistent_install.py b/tundra/tests/test_persistent_install.py index e04941a5..21f711cb 100644 --- a/tundra/tests/test_persistent_install.py +++ b/tundra/tests/test_persistent_install.py @@ -69,7 +69,7 @@ class InstallTests(unittest.TestCase): def test_restore_does_not_require_working_candidate(self): backup=self.folder/'boot_a.img';backup.write_bytes(b'original') - boot=SimpleNamespace(checked_plan=lambda:({'serialSha256':'serial','backupDirectory':str(self.folder)},None), + boot=SimpleNamespace(checked_backup_plan=lambda:{'serialSha256':'serial','backupDirectory':str(self.folder)}, find_fastboot=lambda *args:'device',check_device=lambda *args:{'product':'sargo'}) with patch.object(sys,'argv',['install','--build',str(self.folder),'--restore']), \ patch.object(install,'load',return_value=boot), \ diff --git a/tundra/tests/test_recovery_kit.py b/tundra/tests/test_recovery_kit.py new file mode 100644 index 00000000..2ac902a5 --- /dev/null +++ b/tundra/tests/test_recovery_kit.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: MPL-2.0 +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +TOOLS = Path(__file__).resolve().parents[1]/'tools' +spec = importlib.util.spec_from_file_location('recovery', TOOLS/'restore-sargo.py') +recovery = importlib.util.module_from_spec(spec) +spec.loader.exec_module(recovery) + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.image = self.root/'boot_a.img' + self.image.write_bytes(b'ANDROID!'+bytes(4088)) + self.size = patch.object(recovery, 'BOOT_SIZE', 4096) + self.size.start(); self.addCleanup(self.size.stop) + self.manifest = {'schema': 1, 'device': 'sargo', 'partition': 'boot_a', 'slot': 'a', + 'image': 'boot_a.img', 'bytes': 4096, + 'serialSha256': hashlib.sha256(b'PHONE').hexdigest(), + 'imageSha256': recovery.digest(self.image), 'backupManifestSha256': 'a'*64} + self.save() + + def save(self): + (self.root/'recovery.json').write_text(json.dumps(self.manifest)) + + def test_local_review_does_not_require_checkout_or_phone(self): + with patch.object(sys, 'argv', ['restore', '--kit', str(self.root)]), \ + patch.object(recovery.subprocess, 'run') as run, \ + patch.object(recovery.subprocess, 'check_output') as output, \ + patch.object(recovery.os, 'umask'): + recovery.main() + run.assert_not_called(); output.assert_not_called() + + def test_corrupted_backup_rejected(self): + self.image.write_bytes(b'ANDROID!'+b'x'*4088) + with self.assertRaisesRegex(ValueError, 'checksum mismatch'): + recovery.checked_kit(self.root) + + def test_incomplete_export_cannot_restore(self): + (self.root/'.incomplete').touch() + with patch.object(sys, 'argv', ['restore', '--kit', str(self.root), '--restore']), \ + patch.object(recovery, 'checked_device') as device: + with self.assertRaisesRegex(ValueError, 'did not finish'): + recovery.main() + device.assert_not_called() + + def test_missing_header_even_with_matching_hash_rejected(self): + self.image.write_bytes(bytes(4096)) + self.manifest['imageSha256'] = recovery.digest(self.image); self.save() + with self.assertRaisesRegex(ValueError, 'boot header'): + recovery.checked_kit(self.root) + + def test_other_slot_and_path_are_rejected(self): + for key, value in (('partition', 'userdata'), ('slot', 'b'), ('image', '../boot_a.img')): + original = self.manifest[key] + self.manifest[key] = value; self.save() + with self.assertRaises(ValueError): + recovery.checked_kit(self.root) + self.manifest[key] = original + + def test_wrong_phone_never_gets_a_write(self): + with patch.object(recovery.subprocess, 'check_output', return_value='OTHER fastboot\n'), \ + patch.object(recovery.subprocess, 'run') as run: + with self.assertRaisesRegex(ValueError, 'not uniquely present'): + recovery.checked_device(self.manifest) + run.assert_not_called() + + def test_locked_slot_b_wrong_product_and_wrong_partition_size_rejected(self): + original = {'product': 'sargo', 'unlocked': 'yes', 'current-slot': 'a', + 'partition-size:boot_a': '0x1000'} + for name, value in (('product', 'bonito'), ('unlocked', 'no'), + ('current-slot', 'b'), ('partition-size:boot_a', '0x2000')): + values = dict(original, **{name: value}) + with patch.object(recovery.subprocess, 'check_output', return_value='PHONE fastboot\n'), \ + patch.object(recovery, 'getvar', side_effect=lambda serial, key: values[key]): + with self.assertRaises(ValueError): + recovery.checked_device(self.manifest) + + def test_restore_only_writes_boot_a_without_reboot(self): + with patch.object(sys, 'argv', ['restore', '--kit', str(self.root), '--restore']), \ + patch.object(recovery, 'checked_device', return_value='PHONE'), \ + patch.object(recovery.subprocess, 'run') as run, \ + patch.object(recovery.os, 'umask'): + recovery.main() + run.assert_called_once_with(['fastboot', '-s', 'PHONE', 'flash', 'boot_a', + str(self.image.resolve())], check=True, timeout=90) + self.assertTrue(json.loads((self.root/'restore-report.json').read_text())['completed']) + + def test_flash_failure_is_recorded_as_incomplete(self): + with patch.object(sys, 'argv', ['restore', '--kit', str(self.root), '--restore']), \ + patch.object(recovery, 'checked_device', return_value='PHONE'), \ + patch.object(recovery.subprocess, 'run', side_effect=subprocess.CalledProcessError(1, 'fastboot')), \ + patch.object(recovery.os, 'umask'): + with self.assertRaises(subprocess.CalledProcessError): + recovery.main() + self.assertFalse(json.loads((self.root/'restore-report.json').read_text())['completed']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tundra/tests/test_system_image.py b/tundra/tests/test_system_image.py index b1931d19..eebd46f8 100644 --- a/tundra/tests/test_system_image.py +++ b/tundra/tests/test_system_image.py @@ -15,6 +15,27 @@ spec.loader.exec_module(image) class SystemImageTests(unittest.TestCase): + def test_hardware_reference_requires_services_not_just_display(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + value = {'architecture': 'arm64', 'packages': {'phoc': {}}, 'files': {}} + path = root/'manifest.json' + path.write_text(json.dumps(value)) + with self.assertRaisesRegex(ValueError, 'missing packages'): + image.validate_reference(root) + value['packages'] = {name+':arm64': {} for name in image.HARDWARE_PACKAGES} + path.write_text(json.dumps(value)) + with self.assertRaisesRegex(ValueError, 'executable'): + image.validate_reference(root) + value['files'] = {name: {} for name in + ('/usr/sbin/ofonod', '/usr/sbin/NetworkManager', '/usr/bin/phoc')} + path.write_text(json.dumps(value)) + image.validate_reference(root) + value['architecture'] = 'amd64' + path.write_text(json.dumps(value)) + with self.assertRaisesRegex(ValueError, 'ARM64'): + image.validate_reference(root) + def test_distribution_omits_working_documents_but_keeps_component_licenses(self): with tempfile.TemporaryDirectory() as temporary: root=Path(temporary); product=root/'product'; reference=root/'reference' diff --git a/tundra/tests/test_temporary_boot.py b/tundra/tests/test_temporary_boot.py index d442b4b3..5a519bda 100644 --- a/tundra/tests/test_temporary_boot.py +++ b/tundra/tests/test_temporary_boot.py @@ -19,6 +19,28 @@ spec.loader.exec_module(boot) class TemporaryBootTests(unittest.TestCase): + def test_recovery_checks_backup_without_requiring_diagnostic(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp); logs = root/'logs/device'; logs.mkdir(parents=True) + backup = root/'backup'; backup.mkdir() + (backup/'manifest.json').write_text('{}') + plan = {'device': 'sargo', 'activeSlot': 'a', 'serialSha256': 'a'*64, + 'backupDirectory': str(backup), + 'backupManifestSha256': boot.digest(backup/'manifest.json'), + 'diagnosticImage': 'missing.img'} + report = logs/'boot-backup-qualification.json' + report.write_text(json.dumps(plan)) + (root/'VALIDATION.json').write_text(json.dumps({ + 'bootBackup': {'reportSha256': boot.digest(report)}})) + with patch.object(boot, 'ROOT', root), patch.object(boot, 'verify') as verify: + self.assertEqual(boot.checked_backup_plan(), plan) + verify.assert_called_once_with(backup) + with self.assertRaises(FileNotFoundError): + boot.checked_plan() + (backup/'manifest.json').write_text('{"changed": true}') + with self.assertRaisesRegex(ValueError, 'Backup manifest changed'): + boot.checked_backup_plan() + def test_different_phone_rejected_without_fastboot_queries(self): with patch.object(boot,'getvar') as getvar: with self.assertRaisesRegex(ValueError,'differs from the inventoried'): diff --git a/tundra/tools/build-system-image.py b/tundra/tools/build-system-image.py index 709199a0..898afae2 100644 --- a/tundra/tools/build-system-image.py +++ b/tundra/tools/build-system-image.py @@ -14,6 +14,23 @@ import sys ROOT = Path(__file__).resolve().parents[1] PROJECT = ROOT.parent ANDROID_SHA = '132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886' +HARDWARE_PACKAGES = {'systemd', 'phoc', 'libhybris', 'ofono', + 'ofono-binder-plugin', 'network-manager', 'pulseaudio'} + + +def validate_reference(reference): + manifest = json.loads((reference/'manifest.json').read_text()) + if manifest.get('architecture') != 'arm64': + raise ValueError('Sargo requires an ARM64 runtime reference') + packages = {name.split(':', 1)[0] for name in manifest.get('packages', {})} + missing = HARDWARE_PACKAGES - packages + if missing: + raise ValueError('Incomplete hardware runtime; missing packages: '+', '.join(sorted(missing))) + files = manifest.get('files', {}) + for name in ('/usr/sbin/ofonod', '/usr/sbin/NetworkManager', '/usr/bin/phoc'): + if name not in files: + raise ValueError('Missing hardware executable in runtime inventory: '+name) + return manifest def digest(path): @@ -80,15 +97,18 @@ def pack(root, image): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--reference', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference-20260925') + parser.add_argument('--reference', type=Path, help='Explicit ARM64 hardware runtime reference') parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product') parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS) args = parser.parse_args() if args.pack: pack(*args.pack) return + if args.reference is None: + parser.error('--reference is required; display-only runtime references are not sufficient') if os.geteuid() == 0: parser.error('Build as a regular user') + validate_reference(args.reference) android = ROOT/'downloads/sargo-installed/android-rootfs.img' if digest(android) != ANDROID_SHA: raise ValueError('Android HAL reference changed') diff --git a/tundra/tools/export-sargo-recovery.py b/tundra/tools/export-sargo-recovery.py new file mode 100644 index 00000000..ecd546b3 --- /dev/null +++ b/tundra/tools/export-sargo-recovery.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Export the qualified original boot backup and a standalone restoration tool. + +The kit is private and specific to one phone. Keep it outside the project and +do not upload it as a release. Exporting performs no phone operations. +""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shutil +from tundra import ROOT, digest + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def export(plan, output): + output = Path(output).absolute() + if output.exists() or output.is_symlink(): + raise ValueError('Recovery output exists; keep the existing backup') + backup = Path(plan['backupDirectory']) + from boot_backup import verify + manifest = verify(backup) + if digest(backup/'manifest.json') != plan['backupManifestSha256']: + raise ValueError('Backup manifest changed') + output.mkdir(mode=0o700) + marker = output/'.incomplete' + marker.write_text('Recovery export incomplete\n') + shutil.copyfile(backup/'boot_a.img', output/'boot_a.img') + (output/'boot_a.img').chmod(0o600) + tool = ROOT/'tools/restore-sargo.py' + shutil.copyfile(tool, output/tool.name) + (output/tool.name).chmod(0o700) + recovery = {'schema': 1, 'device': 'sargo', 'partition': 'boot_a', 'slot': 'a', + 'serialSha256': plan['serialSha256'], 'image': 'boot_a.img', + 'bytes': manifest['files']['boot_a.img']['bytes'], + 'imageSha256': manifest['files']['boot_a.img']['sha256'], + 'backupManifestSha256': plan['backupManifestSha256'], + 'restoreToolSha256': digest(tool), 'private': True, + 'scope': 'Original qualified boot_a only; not a userdata backup or a generic ROM'} + (output/'recovery.json').write_text(json.dumps(recovery, indent=2)+'\n') + load('restore_check', tool).checked_kit(output) + marker.unlink() + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + os.umask(0o077) + plan = load('backup_plan', ROOT/'tools/test-sargo-boot.py').checked_backup_plan() + print(export(plan, args.output)) + print('Keep this private kit on another disk. Its boot image belongs only to this phone.') + + +if __name__ == '__main__': + main() diff --git a/tundra/tools/install-sargo.py b/tundra/tools/install-sargo.py index 4214fca4..9680d86f 100644 --- a/tundra/tools/install-sargo.py +++ b/tundra/tools/install-sargo.py @@ -58,12 +58,13 @@ def main(): action.add_argument('--restore',action='store_true',help='Restore the verified original boot_a backup; retain Tundra data') args=parser.parse_args();os.umask(0o077) boot=load('sargo_boot',ROOT/'tools/test-sargo-boot.py') - plan,_=boot.checked_plan() if args.restore: # Recovery must still work if the candidate or its proof was damaged. + plan=boot.checked_backup_plan() image=Path(plan['backupDirectory'])/'boot_a.img' build={'serialSha256':plan['serialSha256']} else: + plan,_=boot.checked_plan() build,qualification,image=checked_build(args.build) if plan['serialSha256']!=build['serialSha256']:raise ValueError('Build targets another phone') print('Private Sargo installation plan') diff --git a/tundra/tools/prepare-sargo-package.py b/tundra/tools/prepare-sargo-package.py new file mode 100644 index 00000000..8623af79 --- /dev/null +++ b/tundra/tools/prepare-sargo-package.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Verify a Sargo system archive and prepare fresh local installation files. + +This tool never connects to a phone. The result is a userspace image and a new +data image, not a flashable ROM. A device-specific boot/recovery qualification +is still required. Get the manifest checksum through a trusted release channel. +""" +import argparse +import hashlib +import json +import lzma +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + +from persistent_state import digest, validate_manifest + +CHUNK = 1024 * 1024 +TOOLS = Path(__file__).resolve().parent + + +def sha256(value): + if not isinstance(value, str) or not re.fullmatch(r'[a-f0-9]{64}', value): + raise ValueError('Expected a SHA256 checksum') + return value + + +def checked_manifest(path, expected): + path = Path(path) + if path.is_symlink() or not path.is_file() or path.stat().st_size > CHUNK: + raise ValueError('Expected a regular package manifest smaller than 1 MiB') + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != sha256(expected): + raise ValueError('Package manifest checksum mismatch') + package = json.loads(data) + if package.get('device') != 'sargo': + raise ValueError('Only Sargo system packages are supported') + name = package.get('systemImage', '') + if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*\.ext4\.xz', name): + raise ValueError('Archive must be a plain .ext4.xz filename') + if not isinstance(package.get('version'), str) or not package['version'].strip(): + raise ValueError('Missing package version') + for key in ('archiveSha256', 'systemImageSha256'): + sha256(package.get(key)) + for key, maximum in (('archiveBytes', 2 * 1024**3), ('uncompressedBytes', 8 * 1024**3)): + size = package.get(key) + if type(size) is not int or not 0 < size <= maximum: + raise ValueError('Invalid '+key) + if package['uncompressedBytes'] % 4096: + raise ValueError('System image size must be aligned to 4096 bytes') + for key in ('filesystemCheckPassed', 'compressionCheckPassed'): + if package.get(key) is not True: + raise ValueError('Package has not passed '+key) + return package + + +def checked_archive(folder, package): + archive = Path(folder)/package['systemImage'] + if archive.is_symlink() or not archive.is_file(): + raise ValueError('Expected a regular archive beside the manifest') + if archive.stat().st_size != package['archiveBytes'] or digest(archive) != package['archiveSha256']: + raise ValueError('System archive checksum or size mismatch') + return archive + + +def unpack(archive, image, package): + """Bound both memory and output; reject trailing data and truncated streams.""" + decoder = lzma.LZMADecompressor(format=lzma.FORMAT_XZ, memlimit=128 * CHUNK) + compressed = hashlib.sha256() + expanded = hashlib.sha256() + total = count = 0 + created = False + try: + with Path(archive).open('rb') as source, Path(image).open('xb') as target: + created = True + while chunk := source.read(CHUNK): + if decoder.eof: + raise ValueError('Trailing data after XZ stream') + compressed.update(chunk) + count += len(chunk) + if count > package['archiveBytes']: + raise ValueError('Archive exceeds declared size') + while True: + block = decoder.decompress(chunk, max_length=CHUNK) + chunk = b'' + total += len(block) + if total > package['uncompressedBytes']: + raise ValueError('System image exceeds declared size') + expanded.update(block) + target.write(block) + if decoder.eof or decoder.needs_input: + break + if decoder.unused_data: + raise ValueError('Trailing data after XZ stream') + if not decoder.eof: + raise ValueError('Truncated XZ stream') + if (count != package['archiveBytes'] or total != package['uncompressedBytes'] + or compressed.hexdigest() != package['archiveSha256'] + or expanded.hexdigest() != package['systemImageSha256']): + raise ValueError('Expanded image or archive checksum mismatch') + target.flush() + os.fsync(target.fileno()) + except BaseException: + if created: + Path(image).unlink() + raise + + +def prepare(manifest, expected, output, size_mib): + if type(size_mib) is not int or not 512 <= size_mib <= 8192: + raise ValueError('Choose 512–8192 MiB for fresh data') + package = checked_manifest(manifest, expected) + archive = checked_archive(Path(manifest).parent, package) + output = Path(output).absolute() + if output.exists() or output.is_symlink(): + raise ValueError('Output already exists; existing installation data must not be overwritten') + if not output.parent.is_dir(): + raise ValueError('Create the output parent directory first') + for tool in ('e2fsck', 'mke2fs', 'debugfs'): + if not shutil.which(tool): + raise ValueError('Missing required program: '+tool) + required = package['uncompressedBytes'] + size_mib * CHUNK + 256 * CHUNK + if shutil.disk_usage(output.parent).free < required: + raise ValueError('Insufficient local disk space for system, data and reserve') + output.mkdir(mode=0o700) + marker = output/'.incomplete' + marker.write_text('Preparation did not finish. Do not use these files.\n') + image = output/'tundra-sargo-system.ext4' + unpack(archive, image, package) + check = subprocess.run(['e2fsck', '-f', '-n', str(image)], + capture_output=True, text=True, timeout=300) + (output/'filesystem-check.txt').write_text(check.stdout+check.stderr) + if check.returncode: + raise ValueError('System filesystem check failed; no data image created') + system = {'schema': 1, 'device': 'sargo', 'image': image.name, + 'imageSha256': package['systemImageSha256'], 'bytes': image.stat().st_size, + 'filesystemCheckPassed': True, 'flashReady': False, + 'packageVersion': package['version'], 'packageManifestSha256': expected} + system_path = output/'system.json' + system_path.write_text(json.dumps(system, indent=2)+'\n') + subprocess.run([sys.executable, str(TOOLS/'build-state-image.py'), '--output', str(output/'data'), + '--system-image', str(system_path), '--size-mib', str(size_mib)], + check=True, timeout=300) + state = validate_manifest(output/'data/state.json') + if state['systemImageSha256'] != system['imageSha256']: + raise ValueError('Data image belongs to another system image') + report = {'schema': 1, 'device': 'sargo', 'version': package['version'], + 'packageManifestSha256': expected, 'systemImageSha256': system['imageSha256'], + 'stateUuid': state['uuid'], 'localPreparationPassed': True, + 'phoneTouched': False, 'flashReady': False, + 'remaining': ['Per-phone storage and boot backup inventory', + 'Device-specific boot image and recovery qualification', + 'Transfer, two RAM boots and persistence check before installation'], + 'supportedStorage': 'Sargo userdata with an inventoried single linear ext4 extent; not stock Android/F2FS'} + (output/'preparation.json').write_text(json.dumps(report, indent=2)+'\n') + marker.unlink() + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', required=True, type=Path) + parser.add_argument('--manifest-sha256', required=True) + parser.add_argument('--prepare', type=Path, metavar='NEW_DIRECTORY', + help='Expand and check the system; create separate fresh data locally') + parser.add_argument('--data-mib', type=int, default=1024) + args = parser.parse_args() + os.umask(0o077) + if args.prepare: + report = prepare(args.manifest, args.manifest_sha256, args.prepare, args.data_mib) + print(json.dumps(report, indent=2)) + else: + package = checked_manifest(args.manifest, args.manifest_sha256) + checked_archive(args.manifest.parent, package) + print('Manifest and compressed archive verified. No extraction or phone operation performed.') + print('Not a flashable ROM. Never fastboot flash this ext4 file to userdata.') + + +if __name__ == '__main__': + main() diff --git a/tundra/tools/restore-sargo.py b/tundra/tools/restore-sargo.py new file mode 100644 index 00000000..167ed039 --- /dev/null +++ b/tundra/tools/restore-sargo.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Restore boot_a from a private recovery kit. Runs without the Vulpes checkout. + +By default this only verifies local files. --restore writes the saved boot_a +to the matching unlocked Sargo on slot A. It never erases userdata, changes +slots or reboots automatically. This does not restore or back up user data. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + +BOOT_SIZE = 67108864 + + +def digest(path): + with Path(path).open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def checked_kit(folder): + folder = Path(folder) + path = folder/'recovery.json' + if path.is_symlink() or not path.is_file() or path.stat().st_size > 65536: + raise ValueError('Expected a regular recovery manifest') + manifest = json.loads(path.read_text()) + if (manifest.get('schema') != 1 or manifest.get('device') != 'sargo' + or manifest.get('partition') != 'boot_a' or manifest.get('slot') != 'a' + or manifest.get('image') != 'boot_a.img' or manifest.get('bytes') != BOOT_SIZE): + raise ValueError('Expected a Sargo slot A recovery kit') + for key in ('serialSha256', 'imageSha256', 'backupManifestSha256'): + if not re.fullmatch(r'[a-f0-9]{64}', str(manifest.get(key, ''))): + raise ValueError('Missing recovery checksum: '+key) + image = folder/'boot_a.img' + if image.is_symlink() or not image.is_file() or image.stat().st_size != BOOT_SIZE: + raise ValueError('Saved boot_a is missing or has the wrong size') + if digest(image) != manifest['imageSha256']: + raise ValueError('Saved boot_a checksum mismatch') + with image.open('rb') as stream: + if stream.read(8) != b'ANDROID!': + raise ValueError('Saved boot_a has no Android boot header') + return manifest, image + + +def getvar(serial, name): + result = subprocess.run(['fastboot', '-s', serial, 'getvar', name], + capture_output=True, text=True, check=True, timeout=15) + for line in (result.stdout+'\n'+result.stderr).splitlines(): + match = re.fullmatch(r'(?:\(bootloader\)\s*)?'+re.escape(name)+r':\s*(.+)', line.strip()) + if match: + return match[1].strip() + raise ValueError('Fastboot did not return '+name) + + +def checked_device(manifest): + output = subprocess.check_output(['fastboot', 'devices'], text=True, timeout=10) + matches = [] + for line in output.splitlines(): + fields = line.split() + if (len(fields) == 2 and fields[1] == 'fastboot' + and hashlib.sha256(fields[0].encode()).hexdigest() == manifest['serialSha256']): + matches.append(fields[0]) + if len(matches) != 1: + raise ValueError('The phone belonging to this backup is not uniquely present in Fastboot') + serial = matches[0] + values = {name: getvar(serial, name) for name in + ('product', 'unlocked', 'current-slot', 'partition-size:boot_a')} + if (values['product'] != 'sargo' or values['unlocked'] != 'yes' + or values['current-slot'] != 'a' + or int(values['partition-size:boot_a'], 16) != BOOT_SIZE): + raise ValueError('Expected the matching unlocked Sargo on slot A with a 64 MiB boot partition') + return serial + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--kit', type=Path, default=Path(__file__).resolve().parent) + parser.add_argument('--restore', action='store_true') + args = parser.parse_args() + os.umask(0o077) + if (args.kit/'.incomplete').exists(): + raise ValueError('Recovery kit export did not finish') + manifest, image = checked_kit(args.kit) + print('Saved boot_a verified:', manifest['imageSha256']) + if not args.restore: + print('Local verification only. No phone command sent.') + return + serial = checked_device(manifest) + # Verify again after connecting, before allowing the only write command. + current, image = checked_kit(args.kit) + if current != manifest: + raise ValueError('Recovery kit changed during device verification') + report = {'operation': 'restore', 'partition': 'boot_a', + 'imageSha256': manifest['imageSha256'], 'completed': False} + path = args.kit/'restore-report.json' + path.write_text(json.dumps(report, indent=2)+'\n') + subprocess.run(['fastboot', '-s', serial, 'flash', 'boot_a', str(image.resolve())], + check=True, timeout=90) + report['completed'] = True + path.write_text(json.dumps(report, indent=2)+'\n') + print('Saved boot_a restored. Select Start on the phone. No userdata erase or slot change sent.') + + +if __name__ == '__main__': + main() diff --git a/tundra/tools/test-sargo-boot.py b/tundra/tools/test-sargo-boot.py index 7d1a2139..fe10dff6 100644 --- a/tundra/tools/test-sargo-boot.py +++ b/tundra/tools/test-sargo-boot.py @@ -17,7 +17,8 @@ from tundra import ROOT, digest, now, save from usb_protocol import exchange, device_layout -def checked_plan(): +def checked_backup_plan(): + """Recovery only needs the qualified backup, not a working diagnostic.""" path = ROOT/'logs/device/boot-backup-qualification.json' validation = json.loads((ROOT/'VALIDATION.json').read_text()) if validation.get('bootBackup', {}).get('reportSha256') != digest(path): @@ -27,6 +28,14 @@ def checked_plan(): verify(backup) if digest(backup/'manifest.json') != plan['backupManifestSha256']: raise ValueError('Backup manifest changed; requalify') + if (plan.get('device') != 'sargo' or plan.get('activeSlot') != 'a' + or not re.fullmatch(r'[0-9a-f]{64}', plan.get('serialSha256', ''))): + raise ValueError('Backup qualification must identify a Sargo on slot A') + return plan + + +def checked_plan(): + plan = checked_backup_plan() image = ROOT/plan['diagnosticImage'] if digest(image) != plan['diagnosticImageSha256'] or digest(image.parent/'build.json') != plan['diagnosticBuildSha256']: raise ValueError('Diagnostic image/build changed; requalify')