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