VulpesOS/tundra/tools/prepare-sargo-package.py

184 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()