Prepare verified Sargo packages and independent boot recovery

This commit is contained in:
OverSu 2026-10-03 22:33:47 +02:00
commit 978542bc87
14 changed files with 665 additions and 8 deletions

View file

@ -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()

View file

@ -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), \

View file

@ -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()

View file

@ -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'

View file

@ -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'):