Prepare Sargo installations with private per-device workspaces

Bind backups, storage layout, RAM builds, file staging and recovery to each phone. Verify atomic transfers without replacing existing state and record the unresolved Android HAL source provenance.

Validated with 53 native tooling tests, 5 changelog tests and two RAM boots with persistent Gaia state on the development Sargo. No new flash or native binary release.
This commit is contained in:
OverSu 2026-10-04 00:06:13 +02:00
commit d04824ec89
16 changed files with 707 additions and 7 deletions

View file

@ -0,0 +1,70 @@
{
"schema": 1,
"device": "sargo",
"role": "Android HAL userspace reference; vendor/firmware remain device-supplied",
"image": {
"sha256": "132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886",
"bytes": 281219072
},
"installedPackage": {
"name": "android-system-google-sargo",
"version": "9.0.0+r1+ubports20230620+git20230322023537.774b882.bookworm.production",
"source": "android-system-google-sargo-bin"
},
"packaging": {
"repository": "https://github.com/droidian-devices/android-system-google-sargo-bin",
"commit": "774b88272f151a0d58d5cea493de10274168cf5e",
"downloadPinned": false,
"downloadBehavior": "The packaging script selects the latest UBports CI artifact at build time."
},
"candidateOrigin": {
"url": "https://ci.ubports.com/job/UBportsCommunityPortsJenkinsCI/job/ubports%252Fporting%252Fcommunity-ports%252Fjenkins-ci%252Fsargo/job/main/359/",
"inferredFrom": [
"ro.build.version.incremental=359",
"ro.build.date.utc=1687228550",
"package ubports20230620"
],
"artifactChecksumMatchVerified": false,
"artifactsCurrentlyListed": 0,
"recipes": [
{
"url": "https://gitlab.com/ubports/porting/community-ports/jenkins-ci/halium-build-tools.git",
"commit": "681a03bfc1f8b243be1a12624380fdedcbf7f1a9",
"fetched": true
},
{
"url": "https://gitlab.com/ubports/porting/community-ports/jenkins-ci/sargo.git",
"commit": "14e070112338860d14ace77320424939bb5f519b",
"fetched": true
}
],
"sourceManifestArtifacts": [
{
"artifact": "used-repos.txt",
"url": "https://ci.ubports.com/job/UBportsCommunityPortsJenkinsCI/job/ubports%252Fporting%252Fcommunity-ports%252Fjenkins-ci%252Fsargo/job/main/359/artifact/used-repos.txt",
"error": "HTTP Error 404: Not Found"
},
{
"artifact": "last-used-repos.txt",
"url": "https://ci.ubports.com/job/UBportsCommunityPortsJenkinsCI/job/ubports%252Fporting%252Fcommunity-ports%252Fjenkins-ci%252Fsargo/job/main/359/artifact/last-used-repos.txt",
"error": "HTTP Error 404: Not Found"
}
],
"artifactRetentionCount": 30,
"retentionRecipeSha256": "8715a60456a0706e79d9a06436df5b608aee95e03d65fa4fd5b8c1d0f05a751e"
},
"embeddedNotices": {
"path": "/system/etc/NOTICE.xml.gz",
"sha256": "95bfb3fb3cd81bcd0847990df121ec3aeedd4065177aabc22ac98e8a13dd8dee",
"fileNameTags": 482,
"licenseTextTags": 70,
"strictXmlParsingPassed": false,
"originalPreserved": true
},
"sourceReviewComplete": false,
"remaining": [
"Recover or reconstruct the resolved Android source manifest and exact external dependencies",
"Establish a verifiable correspondence between the archived build and this binary image",
"Complete component-level redistribution review; the packaging license alone is insufficient"
]
}

View file

@ -1,9 +1,11 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
@ -32,5 +34,13 @@ class SourceSnapshotTests(unittest.TestCase):
self.build['sources']={'../../outside':'hash'}
with self.assertRaises(ValueError):rescue.verify_sources(self.folder,self.build)
def test_workspace_required_before_any_device_command(self):
(self.folder/'build.json').write_text(json.dumps({'image':'boot.img','deviceWorkspaceSha256':'a'*64}))
with patch.object(sys,'argv',['test-rescue-boot','--build',str(self.folder),'--boot']), \
patch.object(rescue.subprocess,'run') as run:
with self.assertRaisesRegex(ValueError,'requires --device-workspace'):
rescue.main()
run.assert_not_called()
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,113 @@
# SPDX-License-Identifier: MPL-2.0
import hashlib
import importlib.util
import json
from pathlib import Path
import struct
import sys
import tempfile
import unittest
from unittest.mock import patch
TOOLS = Path(__file__).resolve().parents[1]/'tools'
sys.path.insert(0, str(TOOLS))
import boot_backup
import sargo_workspace as workspace
def load(name):
spec = importlib.util.spec_from_file_location(name, TOOLS/(name+'.py'))
value = importlib.util.module_from_spec(spec); spec.loader.exec_module(value)
return value
prepare = load('prepare-sargo-device')
collect = load('collect-sargo-installation')
class WorkspaceTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
self.source = self.root/'inventory'; (self.source/'backup').mkdir(parents=True)
sizes = {name: 16384 for name in boot_backup.SIZES}
patcher = patch.object(boot_backup, 'SIZES', sizes); patcher.start(); self.addCleanup(patcher.stop)
kernel = b'test kernel'
patcher = patch.object(workspace, 'KERNEL_SHA256', hashlib.sha256(kernel).hexdigest())
patcher.start(); self.addCleanup(patcher.stop)
boot = bytearray(16384); boot[:8] = b'ANDROID!'
struct.pack_into('<10I', boot, 8, len(kernel), 0x8000, 1, 0x1000000, 0, 0, 0x100, 4096, 0, 0)
boot[4096:4096+len(kernel)] = kernel
manifest = {'device': 'sargo', 'rawPartitions': True, 'serialSha256': 'a'*64,
'activeSlot': 'a', 'files': {}}
for name, size in sizes.items():
path = self.source/'backup'/name; path.write_bytes(boot if name=='boot_a.img' else bytes(size))
manifest['files'][name] = {'bytes': size, 'sha256': workspace.digest(path)}
(self.source/'backup/manifest.json').write_text(json.dumps(manifest))
self.layout = {'schema': 2, 'device': '/dev/mmcblk0p72', 'partitionLabel': 'userdata',
'filesystem': 'ext4', 'backingDevice': '259:40',
'table': '0 104448000 linear 259:40 329728', 'size': 53648801280,
'firstMiBSha256': 'b'*64, 'serialSha256': 'a'*64, 'activeSlot': 'a'}
self.save_layout()
def save_layout(self):
(self.source/'layout.json').write_text(json.dumps(self.layout))
def test_private_workspace_is_portable_without_shared_inventory(self):
output = prepare.prepare(self.source, self.root/'workspace')
moved = self.root/'moved'; output.rename(moved)
plan, layout = workspace.checked_workspace(moved)
self.assertEqual(plan['backupDirectory'], str(moved/'backup'))
self.assertEqual(plan['serialSha256'], 'a'*64)
self.assertEqual(layout, self.layout)
def test_different_phone_storage_is_rejected_before_output(self):
self.layout['serialSha256'] = 'c'*64; self.save_layout()
with self.assertRaisesRegex(ValueError, 'same Sargo'):
prepare.prepare(self.source, self.root/'workspace')
self.assertFalse((self.root/'workspace').exists())
def test_other_filesystem_and_slot_are_rejected(self):
for name, value in (('filesystem', 'f2fs'), ('activeSlot', 'b')):
old = self.layout[name]; self.layout[name] = value; self.save_layout()
with self.assertRaises(ValueError):
prepare.prepare(self.source, self.root/'workspace')
self.layout[name] = old
def test_unknown_kernel_is_rejected(self):
with patch.object(workspace, 'KERNEL_SHA256', '0'*64):
with self.assertRaisesRegex(ValueError, 'not been qualified'):
prepare.prepare(self.source, self.root/'workspace')
def test_corruption_and_missing_workspace_record_rejected(self):
output = prepare.prepare(self.source, self.root/'workspace')
(output/'layout.json').write_text('{}')
with self.assertRaisesRegex(ValueError, 'input changed'):
workspace.checked_workspace(output)
def test_existing_and_incomplete_directories_rejected(self):
with self.assertRaisesRegex(ValueError, 'exists'):
prepare.prepare(self.source, self.source)
(self.source/'.incomplete').touch()
with self.assertRaisesRegex(ValueError, 'did not finish'):
prepare.prepare(self.source, self.root/'workspace')
def test_collector_does_not_expose_serial_in_identity(self):
proc = self.root/'proc'; (proc/'device-tree').mkdir(parents=True)
(proc/'device-tree/compatible').write_bytes(b'google,b4s4-sdm670\0')
(proc/'device-tree/model').write_text('Google Inc. MSM sdm670 S4 PVT v1.0\0')
(proc/'cmdline').write_text('androidboot.serialno=PRIVATE androidboot.slot_suffix=_a')
result = collect.identity(proc)
self.assertEqual(result['serialSha256'], hashlib.sha256(b'PRIVATE').hexdigest())
self.assertNotIn('PRIVATE', json.dumps(result))
(proc/'cmdline').write_text('androidboot.serialno=PRIVATE androidboot.slot_suffix=_b')
with self.assertRaises(ValueError):
collect.identity(proc)
(proc/'cmdline').write_text('androidboot.serialno=PRIVATE androidboot.slot_suffix=_a')
(proc/'device-tree/model').write_text('Google Inc. MSM sdm670 B4 PVT v1.0\0')
with self.assertRaisesRegex(ValueError, 'device tree'):
collect.identity(proc)
if __name__ == '__main__':
unittest.main()

View file

@ -67,6 +67,16 @@ class InstallTests(unittest.TestCase):
patch.object(install,'load',return_value=boot),patch.object(install,'flash_boot_a') as flash:
install.main();flash.assert_not_called()
def test_workspace_build_never_falls_back_to_shared_inventory(self):
self.build['deviceWorkspaceSha256'] = 'a'*64
boot=SimpleNamespace(checked_plan=lambda: self.fail('Shared inventory used'))
with patch.object(sys,'argv',['install','--build',str(self.folder)]), \
patch.object(install,'checked_build',return_value=(self.build,self.qualification,self.image)), \
patch.object(install,'load',return_value=boot), patch.object(install,'flash_boot_a') as flash:
with self.assertRaisesRegex(ValueError, 'requires --device-workspace'):
install.main()
flash.assert_not_called()
def test_restore_does_not_require_working_candidate(self):
backup=self.folder/'boot_a.img';backup.write_bytes(b'original')
boot=SimpleNamespace(checked_backup_plan=lambda:{'serialSha256':'serial','backupDirectory':str(self.folder)},

View file

@ -0,0 +1,91 @@
# SPDX-License-Identifier: MPL-2.0
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
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('stage_files', TOOLS/'stage-sargo-files.py')
stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
class ReceiveTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
paths = {'/proc/device-tree/compatible': b'google,b4s4-sdm670\0',
'/proc/device-tree/model': b'Google Inc. MSM sdm670 S4 PVT v1.0\0',
'/proc/cmdline': b'androidboot.serialno=PHONE androidboot.slot_suffix=_a',
'/sys/class/block/mmcblk0p72/dev': b'259:40', '/dev/mmcblk0p72': bytes(1048576)}
for name, content in paths.items():
target = self.root/name.lstrip('/'); target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
self.mount = self.root/'installed'; self.mount.mkdir()
self.config = {'kind': 'data', 'name': 'new.ext4', 'bytes': 4,
'sha256': hashlib.sha256(b'data').hexdigest(), 'requiredBytes': 4,
'serialSha256': hashlib.sha256(b'PHONE').hexdigest(),
'layout': {'firstMiBSha256': hashlib.sha256(bytes(1048576)).hexdigest(),
'size': 53648801280, 'table': '0 104448000 linear 259:40 329728'},
'mount': str(self.mount), 'checkOnly': False}
def run_receiver(self, data=b'data'):
real_path = Path
def path(name):
name = str(name)
return real_path(self.root/name.lstrip('/')) if name.startswith(('/proc/', '/dev/', '/sys/')) else real_path(name)
def command(args, **kwargs):
if args[0] == 'blockdev': return '53648801280'
if args[0] == 'findmnt': return json.dumps({'filesystems': [{'fstype': 'ext4', 'source': '/dev/mapper/test'}]})
if args[0] == 'dmsetup': return '0 104448000 linear 259:40 329728'
raise AssertionError('Unexpected command: '+repr(args))
with patch('pathlib.Path', side_effect=path), patch('stat.S_ISBLK', return_value=True), \
patch('subprocess.check_output', side_effect=command), \
patch('os.statvfs', return_value=SimpleNamespace(f_bavail=10*1024**3, f_frsize=1)), \
patch.object(sys, 'argv', ['receiver', json.dumps(self.config)]), \
patch.object(sys, 'stdin', SimpleNamespace(buffer=io.BytesIO(data))), \
patch.object(sys, 'stdout', io.StringIO()), patch('os.umask'):
exec(compile(stage.RECEIVE, 'receiver', 'exec'), {})
def test_matching_phone_receives_fresh_file(self):
self.run_receiver()
self.assertEqual((self.mount/'tundra/data/new.ext4').read_bytes(), b'data')
def test_existing_data_never_overwritten(self):
target = self.mount/'tundra/data/new.ext4'; target.parent.mkdir(parents=True)
target.write_bytes(b'personal data')
with self.assertRaisesRegex(ValueError, 'Existing data retained'):
self.run_receiver()
self.assertEqual(target.read_bytes(), b'personal data')
def test_short_and_oversized_transfers_leave_no_file(self):
for data in (b'x', b'too long'):
with self.assertRaises(ValueError): self.run_receiver(data)
self.assertEqual(list((self.mount/'tundra/data').iterdir()), [])
def test_other_phone_refused_before_writing(self):
self.config['serialSha256'] = 'a'*64
with self.assertRaisesRegex(ValueError, 'Wrong phone'):
self.run_receiver()
self.assertFalse((self.mount/'tundra').exists())
def test_storage_identity_and_geometry_changes_refused(self):
for key, value in (('firstMiBSha256', 'a'*64), ('table', '0 104448000 linear 259:40 4096')):
old = self.config['layout'][key]; self.config['layout'][key] = value
with self.assertRaises(ValueError):self.run_receiver()
self.config['layout'][key] = old
self.assertFalse((self.mount/'tundra').exists())
def test_preflight_does_not_create_directories(self):
self.config['checkOnly'] = True
with self.assertRaises(SystemExit):self.run_receiver()
self.assertFalse((self.mount/'tundra').exists())
if __name__ == '__main__':unittest.main()

View file

@ -30,6 +30,8 @@ def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--layout',type=Path,required=True)
parser.add_argument('--device-workspace', type=Path,
help='Private per-phone workspace; never use the development inventory')
parser.add_argument('--native',action='store_true',help='Switch to the staged native root with a ten-minute watchdog')
parser.add_argument('--system-image',type=Path,help='system.json for a self-contained image staged at /tundra/images/<sha256>.ext4')
parser.add_argument('--hold-hal',action='store_true',help='Boot basic.target first so HAL startup can be traced over USB')
@ -65,10 +67,16 @@ def main():
raise ValueError('Data image belongs to another system image')
os.umask(0o077)
built_at=int(time.time())
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
plan,_=load_boot().checked_plan()
layout=json.loads(args.layout.read_text())
if args.device_workspace:
from sargo_workspace import checked_workspace
plan, inventoried_layout = checked_workspace(args.device_workspace)
if layout != inventoried_layout:
raise ValueError('Layout does not belong to this device workspace')
else:
plan,_=load_boot().checked_plan()
storage_table = mount_table(layout)
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
source=Path(plan['backupDirectory'])/'boot_a.img'
packager=checked_source('aosp-mkbootimg')
unpack=out/'original'
@ -106,6 +114,8 @@ def main():
shlex.quote(storage_table).encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py',
ROOT/'tools/storage_layout.py']
if args.device_workspace:
sources += [ROOT/'tools/sargo_workspace.py', ROOT/'tools/boot_backup.py']
if state:
# Keep the existing layout intact; only /tundra/data/<UUID>.ext4 is used
# for Tundra state. Opening its containing filesystem RW is necessary.
@ -232,6 +242,7 @@ def main():
save(out/'build.json',{'scope':'Private persistent Sargo development boot; unqualified' if state else ('Private temporary native-root trial; unqualified' if args.native else 'Private temporary SSH boot; installed root read-only; no graphical session'),
'builtAtUnix':built_at,'runtimeConfigOverlay':args.runtime_config,'networkTrial':args.network,'hardwareTrial':args.hardware,'interactiveDiagnostics':args.diagnostics,'image':image.name,'imageSha256':digest(image),'sourceBootSha256':digest(source),
'serialSha256':plan['serialSha256'],'kernelSha256':kernel_hash,
'deviceWorkspaceSha256':plan.get('workspaceSha256'),
'ramdiskSha256':digest(ramdisk),'roundtripPassed':True,'flashReady':False,'localDebugger':args.local_debugger,'watchdogSeconds':None if args.no_deadline else (600 if args.native else 480),'persistentState':state,'storageWrites':bool(state),'nativeRootRequested':args.native,'halHeldForDiagnostics':args.hold_hal,
'layout':layout,'layoutSha256':digest(args.layout),'systemImage':system,'sharedUiHashes':shared_ui,'sourceSnapshot':True,'sources':{str(p.relative_to(ROOT)):digest(p) for p in sources},
'helpers':{n:__import__('hashlib').sha256(b).hexdigest() for n,b in helpers.items()},

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run as root on Sargo to collect boot backups and its existing ext4 layout.
Only regular output files are written. No partition, slot, filesystem, service
or network configuration is changed. Transfer the private output to your PC.
"""
import argparse
import datetime
import fcntl
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import stat
import struct
from boot_backup import SIZES, verify
def identity(proc=Path('/proc')):
compatible = (proc/'device-tree/compatible').read_bytes().split(b'\0')
model = (proc/'device-tree/model').read_text().strip('\0')
if b'google,b4s4-sdm670' not in compatible or ' S4 ' not in model:
raise ValueError('Expected the Sargo device tree')
cmdline = dict(x.split('=', 1) for x in (proc/'cmdline').read_text().split() if '=' in x)
serial = cmdline.get('androidboot.serialno', '')
if not serial or cmdline.get('androidboot.slot_suffix') != '_a':
raise ValueError('Expected a device serial and active slot A')
return {'serialSha256': hashlib.sha256(serial.encode()).hexdigest(), 'activeSlot': 'a'}
def collect(mountpoint, output):
if os.geteuid() != 0:
raise ValueError('Raw partition backup requires root on the phone')
device = identity()
spec = importlib.util.spec_from_file_location('storage', Path(__file__).with_name('inventory-storage.py'))
storage = importlib.util.module_from_spec(spec); spec.loader.exec_module(storage)
layout = {**storage.inventory(mountpoint), **device}
output = Path(output).absolute()
output.mkdir(mode=0o700)
(output/'.incomplete').touch()
backup = output/'backup'; backup.mkdir(mode=0o700)
manifest = {'device': 'sargo', 'rawPartitions': True, **device,
'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'files': {}, 'scope': 'Boot, DTBO and vbmeta only; not a backup of user data'}
for name, size in SIZES.items():
path = (Path('/dev/disk/by-partlabel')/name.removesuffix('.img')).resolve(strict=True)
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb', buffering=0) as source:
if not stat.S_ISBLK(os.fstat(source.fileno()).st_mode):
raise ValueError('Expected a partition block device')
actual = struct.unpack('Q', fcntl.ioctl(source.fileno(), 0x80081272, bytes(8)))[0]
if actual != size:
raise ValueError('Unexpected partition size: '+name)
sha = hashlib.sha256(); left = size
with (backup/name).open('xb') as target:
while left:
block = source.read(min(left, 1024*1024))
if not block:
raise ValueError('Truncated partition read: '+name)
target.write(block); sha.update(block); left -= len(block)
target.flush(); os.fsync(target.fileno())
manifest['files'][name] = {'bytes': size, 'sha256': sha.hexdigest()}
if identity() != device or storage.inventory(mountpoint) != {k:v for k,v in layout.items() if k not in device}:
raise ValueError('Device identity or storage changed during collection')
(backup/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
(output/'layout.json').write_text(json.dumps(layout, indent=2)+'\n')
verify(backup)
(output/'.incomplete').unlink()
return output
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--mountpoint', required=True)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args(); os.umask(0o077)
print(collect(args.mountpoint, args.output))

View file

@ -54,9 +54,14 @@ def export(plan, output):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--device-workspace', type=Path)
args = parser.parse_args()
os.umask(0o077)
plan = load('backup_plan', ROOT/'tools/test-sargo-boot.py').checked_backup_plan()
if args.device_workspace:
from sargo_workspace import checked_workspace
plan, _ = checked_workspace(args.device_workspace)
else:
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.')

View file

@ -53,19 +53,32 @@ def flash_boot_a(serial, image):
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
parser.add_argument('--device-workspace',type=Path)
action=parser.add_mutually_exclusive_group()
action.add_argument('--flash',action='store_true',help='Install this boot image in boot_a; does not reboot automatically')
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')
workspace_plan = None
if args.device_workspace:
from sargo_workspace import checked_workspace
workspace_plan, workspace_layout = checked_workspace(args.device_workspace)
if args.restore:
# Recovery must still work if the candidate or its proof was damaged.
plan=boot.checked_backup_plan()
plan=workspace_plan or 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 workspace_plan:
plan = workspace_plan
if (build.get('deviceWorkspaceSha256') != plan['workspaceSha256']
or build['layout'] != workspace_layout):
raise ValueError('Build was not prepared with this device workspace')
else:
if build.get('deviceWorkspaceSha256'):
raise ValueError('This build requires --device-workspace; no fallback to shared inventory')
plan,_=boot.checked_plan()
if plan['serialSha256']!=build['serialSha256']:raise ValueError('Build targets another phone')
print('Private Sargo installation plan')
print('Boot image:',image)

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Import a phone's own boot backups and storage inventory into a private workspace.
Inputs must come from collect-sargo-installation.py on that phone. This does not
format, connect to, or flash a phone. Stock Android/F2FS is not supported.
"""
import argparse
import json
import os
from pathlib import Path
import shutil
from boot_backup import verify
from sargo_workspace import boot_kernel, checked_workspace
from storage_layout import mount_table
from tundra import digest
def prepare(source, output):
source, output = Path(source).resolve(), Path(output).absolute()
if (source/'.incomplete').exists():
raise ValueError('Source collection did not finish')
if output.exists() or output.is_symlink():
raise ValueError('Workspace exists; existing backups will not be overwritten')
manifest = verify(source/'backup')
layout = json.loads((source/'layout.json').read_text())
import re
serial = manifest.get('serialSha256', '')
if (not re.fullmatch('[a-f0-9]{64}', serial) or layout.get('serialSha256') != serial
or manifest.get('activeSlot') != 'a' or layout.get('activeSlot') != 'a'):
raise ValueError('Collect a bound backup and storage inventory from the same Sargo on slot A')
mount_table(layout)
kernel = boot_kernel(source/'backup/boot_a.img')
output.mkdir(mode=0o700)
(output/'.incomplete').touch()
(output/'backup').mkdir(mode=0o700)
for name in ('manifest.json', *manifest['files']):
shutil.copyfile(source/'backup'/name, output/'backup'/name)
(output/'backup'/name).chmod(0o600)
shutil.copyfile(source/'layout.json', output/'layout.json')
value = {'schema': 1, 'device': 'sargo', 'activeSlot': 'a', 'serialSha256': serial,
'kernelSha256': kernel, 'private': True, 'flashReady': False,
'files': {name: digest(output/name) for name in ('backup/manifest.json', 'layout.json')}}
(output/'device.json').write_text(json.dumps(value, indent=2)+'\n')
# Verify the copied inputs before exposing a usable workspace.
verify(output/'backup')
for name, sha in value['files'].items():
if digest(output/name) != sha:
raise ValueError('Copied input changed: '+name)
(output/'.incomplete').unlink()
checked_workspace(output)
return output
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--inventory', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
os.umask(0o077)
print(prepare(args.inventory, args.output))
print('Private per-phone workspace verified. No phone operation performed.')
if __name__ == '__main__':
main()

View file

@ -0,0 +1,70 @@
# SPDX-License-Identifier: MPL-2.0
"""Private, portable inputs for one Sargo. No shared device inventory paths."""
import hashlib
import json
from pathlib import Path
import re
import struct
from boot_backup import verify
from storage_layout import mount_table
from tundra import digest
# The system/HAL has only been exercised with this kernel and boot geometry.
KERNEL_SHA256 = 'e214e5e363bcf4db6387668ba723299e75009bc128fb0088f706cfb311db0080'
def boot_kernel(path):
with Path(path).open('rb') as stream:
header = stream.read(608)
if len(header) != 608 or header[:8] != b'ANDROID!':
raise ValueError('Expected an Android boot image')
fields = struct.unpack_from('<10I', header, 8)
size, address, ramdisk_size, ramdisk_address, second_size, _, tags, page, version, _ = fields
if (version != 0 or page != 4096 or address != 0x8000
or ramdisk_address != 0x1000000 or tags != 0x100 or second_size
or not 0 < size < 64*1024**2 or not 0 < ramdisk_size < 64*1024**2):
raise ValueError('Boot geometry is not supported by this Sargo adaptation')
ramdisk_offset = page + ((size+page-1)//page)*page
if ramdisk_offset+ramdisk_size > Path(path).stat().st_size:
raise ValueError('Boot payload exceeds the backup')
stream.seek(page)
kernel = stream.read(size)
if len(kernel) != size:
raise ValueError('Truncated kernel')
result = hashlib.sha256(kernel).hexdigest()
if result != KERNEL_SHA256:
raise ValueError('Kernel has not been qualified with this system/HAL')
return result
def checked_workspace(folder):
folder = Path(folder).resolve()
if (folder/'.incomplete').exists():
raise ValueError('Device workspace preparation did not finish')
value = json.loads((folder/'device.json').read_text())
if (value.get('schema') != 1 or value.get('device') != 'sargo'
or value.get('activeSlot') != 'a'
or not re.fullmatch(r'[a-f0-9]{64}', str(value.get('serialSha256', '')))):
raise ValueError('Expected a Sargo slot A workspace with a device identity')
for name, sha in value.get('files', {}).items():
path = folder/name
if (name not in ('backup/manifest.json', 'layout.json') or path.is_symlink()
or not path.resolve().is_relative_to(folder) or digest(path) != sha):
raise ValueError('Workspace input changed: '+name)
if set(value.get('files', {})) != {'backup/manifest.json', 'layout.json'}:
raise ValueError('Workspace is missing its backup or storage inventory')
backup = verify(folder/'backup')
layout = json.loads((folder/'layout.json').read_text())
for record in (backup, layout):
if record.get('serialSha256') != value['serialSha256'] or record.get('activeSlot') != 'a':
raise ValueError('Storage and boot backup must belong to the same phone on slot A')
mount_table(layout)
kernel = boot_kernel(folder/'backup/boot_a.img')
if kernel != value.get('kernelSha256'):
raise ValueError('Workspace kernel checksum changed')
plan = {'device': 'sargo', 'activeSlot': 'a', 'serialSha256': value['serialSha256'],
'backupDirectory': str(folder/'backup'),
'backupManifestSha256': value['files']['backup/manifest.json'],
'workspaceSha256': digest(folder/'device.json')}
return plan, layout

View file

@ -0,0 +1,138 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage prepared system/data files over pinned SSH; never flash a partition.
Provide an SSH configuration with this phone's key and known_hosts. The account
must be root. --native-root selects Tundra's rescue chroot; otherwise use the
existing Linux root with its compatible ext4/LVM mount. Default: local review.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
from persistent_state import validate_manifest
from sargo_workspace import checked_workspace
from tundra import digest
# Runs inside the real phone root. No block device is opened for writing.
RECEIVE = r'''
import hashlib,json,os,pathlib,stat,subprocess,sys,tempfile
config=json.loads(sys.argv[1]);layout=config['layout'];mount=config['mount']
def command(*args):
return subprocess.check_output(args,text=True,timeout=15,env={**os.environ,'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','LC_ALL':'C'}).strip()
cmdline=dict(x.split('=',1) for x in pathlib.Path('/proc/cmdline').read_text().split() if '=' in x)
if ' S4 ' not in pathlib.Path('/proc/device-tree/model').read_text():raise ValueError('Unsupported board model')
if b'google,b4s4-sdm670' not in pathlib.Path('/proc/device-tree/compatible').read_bytes().split(b'\0'):raise ValueError('Wrong model')
if cmdline.get('androidboot.slot_suffix')!='_a' or hashlib.sha256(cmdline.get('androidboot.serialno','').encode()).hexdigest()!=config['serialSha256']:raise ValueError('Wrong phone or slot')
device=pathlib.Path('/dev/mmcblk0p72')
if not stat.S_ISBLK(device.stat().st_mode):raise ValueError('Not a partition')
with device.open('rb') as source:
if hashlib.sha256(source.read(1048576)).hexdigest()!=layout['firstMiBSha256']:raise ValueError('Storage identity changed')
if int(command('blockdev','--getsize64',str(device)))!=layout['size']:raise ValueError('Storage size changed')
mounted=json.loads(command('findmnt','--json','--mountpoint',mount,'--output','SOURCE,FSTYPE'))['filesystems']
if len(mounted)!=1 or mounted[0]['fstype']!='ext4' or not mounted[0]['source'].startswith('/dev/mapper/'):raise ValueError('Unsupported filesystem')
table=command('dmsetup','table',mounted[0]['source']).split()
expected=layout['table'].split();backing=pathlib.Path('/sys/class/block/mmcblk0p72/dev').read_text().strip()
if len(table)!=5 or table[:3]!=expected[:3] or table[3]!=backing or table[4]!=expected[4]:raise ValueError('Storage mapping changed')
root=pathlib.Path(mount)/'tundra'
folder=root/config['kind'];target=folder/config['name']
for p in (root,folder):
if p.is_symlink():raise ValueError('Destination is a symbolic link')
if target.exists() or target.is_symlink():
if config['kind']=='data':raise ValueError('Existing data retained; refusing to overwrite')
if target.is_symlink() or not target.is_file() or target.stat().st_size!=config['bytes']:raise ValueError('Unexpected system file')
with target.open('rb') as f:
if hashlib.file_digest(f,'sha256').hexdigest()!=config['sha256']:raise ValueError('Existing system checksum mismatch')
print(json.dumps({'existingSystemVerified':True}));sys.exit(0)
if config['checkOnly']:
s=os.statvfs(mount)
if s.f_bavail*s.f_frsize < config['requiredBytes']+1024**3:raise ValueError('Insufficient space with 1 GiB reserve')
print(json.dumps({'ready':True}));sys.exit(0)
folder.mkdir(parents=True,exist_ok=True,mode=0o700)
os.umask(0o077)
fd,name=tempfile.mkstemp(prefix='.transfer-',dir=folder);partial=pathlib.Path(name)
try:
count=0;sha=hashlib.sha256()
with os.fdopen(fd,'wb') as output:
while data:=sys.stdin.buffer.read(1048576):
count+=len(data)
if count>config['bytes']:raise ValueError('Oversized transfer')
output.write(data);sha.update(data)
output.flush();os.fsync(output.fileno())
if count!=config['bytes'] or sha.hexdigest()!=config['sha256']:raise ValueError('Transfer checksum mismatch')
os.link(partial,target) # Atomic create; never replace an existing data file.
fd=os.open(folder,os.O_RDONLY);os.fsync(fd);os.close(fd)
print(json.dumps({'staged':True,'sha256':sha.hexdigest(),'bytes':count}))
finally:
partial.unlink(missing_ok=True)
'''
def inputs(prepared):
prepared = Path(prepared)
if (prepared/'.incomplete').exists():
raise ValueError('Package preparation did not finish')
system = json.loads((prepared/'system.json').read_text())
if (system.get('device') != 'sargo' or system.get('image') != 'tundra-sargo-system.ext4'
or system.get('filesystemCheckPassed') is not True):
raise ValueError('Expected a checked system image')
path = prepared/system['image']
if path.is_symlink() or path.stat().st_size != system['bytes'] or digest(path) != system['imageSha256']:
raise ValueError('System image changed')
state = validate_manifest(prepared/'data/state.json')
if state['systemImageSha256'] != system['imageSha256']:
raise ValueError('State and system do not match')
return [('images', system['imageSha256']+'.ext4', path, system),
('data', state['image'], prepared/'data'/state['image'], state)]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepared', required=True, type=Path)
parser.add_argument('--device-workspace', required=True, type=Path)
parser.add_argument('--ssh-config', required=True, type=Path)
parser.add_argument('--host', default='sargo')
parser.add_argument('--native-root', action='store_true')
parser.add_argument('--stage', action='store_true')
args = parser.parse_args(); os.umask(0o077)
plan, layout = checked_workspace(args.device_workspace)
files = inputs(args.prepared)
if not re.fullmatch('[A-Za-z0-9][A-Za-z0-9._-]*', args.host):
parser.error('Use an SSH host alias, not command-line options')
if not args.stage:
print('Local inputs checked. --stage transfers new files; no flash or reboot is implemented.')
return
ssh = ['ssh', '-F', str(args.ssh_config.resolve()), '-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', args.host]
python = ['chroot', '/proc/1/root', '/usr/bin/python3'] if args.native_root else ['/usr/bin/python3']
records = []
for kind, name, path, record in files:
config = {'kind': kind, 'name': name, 'sha256': record['imageSha256'],
'bytes': record['bytes'], 'layout': layout, 'serialSha256': plan['serialSha256'],
'mount': '/reference/installed' if args.native_root else '/', 'checkOnly': True,
'requiredBytes': record['bytes']}
def command():
return ssh+[shlex.join(python+['-c', RECEIVE, json.dumps(config)])]
result = subprocess.run(command(), capture_output=True, text=True, timeout=180)
if result.returncode:
raise RuntimeError('Phone preflight failed: '+result.stderr.strip())
status = json.loads(result.stdout)
if not status.get('existingSystemVerified'):
config['checkOnly'] = False
with path.open('rb') as source:
result = subprocess.run(command(), stdin=source, stdout=subprocess.PIPE,
text=True, check=True, timeout=900)
status = json.loads(result.stdout)
records.append({'kind': kind, 'name': name, **status})
report = {'files': records, 'deviceWorkspaceSha256': plan['workspaceSha256'],
'flashCommandsSent': False, 'rebootRequested': False, 'passed': True}
(args.prepared/'transfer.json').write_text(json.dumps(report, indent=2)+'\n')
print('System and fresh data files verified on the matching phone. No flash or reboot sent.')
if __name__ == '__main__':
main()

View file

@ -20,9 +20,20 @@ def verify_sources(folder, build):
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--build',type=Path,required=True);p.add_argument('--boot',action='store_true');args=p.parse_args()
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--build',type=Path,required=True);p.add_argument('--boot',action='store_true')
p.add_argument('--device-workspace',type=Path)
args=p.parse_args()
spec=importlib.util.spec_from_file_location('boot',ROOT/'tools/test-sargo-boot.py');boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
plan,_=boot.checked_plan();folder=args.build.resolve();build=json.loads((folder/'build.json').read_text());image=folder/build['image']
folder=args.build.resolve();build=json.loads((folder/'build.json').read_text());image=folder/build['image']
if args.device_workspace:
from sargo_workspace import checked_workspace
plan,layout=checked_workspace(args.device_workspace)
if build.get('deviceWorkspaceSha256') != plan['workspaceSha256'] or build['layout'] != layout:
raise ValueError('Build was not prepared with this device workspace')
else:
if build.get('deviceWorkspaceSha256'):
raise ValueError('This build requires --device-workspace; no fallback to shared inventory')
plan,_=boot.checked_plan()
if build['imageSha256']!=digest(image) or build['serialSha256']!=plan['serialSha256'] or not build['roundtripPassed']:
raise ValueError('Image or target provenance mismatch')
verify_sources(folder,build)