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

@ -58,6 +58,8 @@ Notre image de développement Sargo installée a passé les contrôles de démar
**Cette release ne contient pas d'image générique à flasher.** Le boot privé dépend encore de la disposition du stockage du téléphone inventorié, d'images système/données déjà déployées et d'accès de maintenance. Publier ce fichier ne fournirait pas une installation fonctionnelle à un autre Pixel. Les recettes Tundra sont incluses pour les contributeurs ; elles nécessitent un inventaire, des sauvegardes vérifiées et des composants matériels fournis localement. Ne réutilisez pas la sauvegarde d'un autre appareil et ne considérez pas un boot isolé comme une ROM complète. **Cette release ne contient pas d'image générique à flasher.** Le boot privé dépend encore de la disposition du stockage du téléphone inventorié, d'images système/données déjà déployées et d'accès de maintenance. Publier ce fichier ne fournirait pas une installation fonctionnelle à un autre Pixel. Les recettes Tundra sont incluses pour les contributeurs ; elles nécessitent un inventaire, des sauvegardes vérifiées et des composants matériels fournis localement. Ne réutilisez pas la sauvegarde d'un autre appareil et ne considérez pas un boot isolé comme une ROM complète.
Les outils de préparation prennent désormais un dossier privé par appareil : inventaire, sauvegardes, construction RAM, transfert SSH et secours. La procédure reste réservée au noyau qualifié et à un stockage ext4/LVM compatible ; elle ne permet pas encore de partir directement d’Android d’origine.
## ✨ Ce que nous partageons ## ✨ Ce que nous partageons
- **Une base Gaia :** interface et services communs pour PC, Android et Tundra. - **Une base Gaia :** interface et services communs pour PC, Android et Tundra.

View file

@ -58,6 +58,8 @@ Our installed Sargo development image has passed startup and persistence checks.
**A generic flashable image is not included in this release.** The current private boot depends on the inventoried phone's storage layout, staged system/data images and maintenance credentials. Publishing that file would not give another Pixel a working installation. Tundra's source recipes are included for contributors; they require device inventory, verified backups and locally supplied hardware components. Do not use another device's backup or treat a boot image as a complete ROM. **A generic flashable image is not included in this release.** The current private boot depends on the inventoried phone's storage layout, staged system/data images and maintenance credentials. Publishing that file would not give another Pixel a working installation. Tundra's source recipes are included for contributors; they require device inventory, verified backups and locally supplied hardware components. Do not use another device's backup or treat a boot image as a complete ROM.
Preparation tools now accept a private workspace per device: inventory, backups, RAM builds, SSH transfers and recovery. The procedure remains limited to the qualified kernel and compatible ext4/LVM storage; it does not yet install directly from stock Android.
## ✨ What is shared ## ✨ What is shared
- **One Gaia base:** interface and common services serve desktop, Android and Tundra. - **One Gaia base:** interface and common services serve desktop, Android and Tundra.

View file

@ -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." "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": [ "entries": [
{
"id": "2026-10-03-sargo-per-device-installation",
"text": {
"fr": "## En développement — 3 octobre 2026 — Installation Sargo par appareil\n\n### Tundra · Sargo\n\n- Collecte des six partitions de démarrage en lecture seule et du stockage existant, liés à l’identité du téléphone et au slot A. Les sauvegardes restent privées.\n- Dossier appareil portable : construction RAM, contrôle du boot, installation et export du secours peuvent l’utiliser sans l’inventaire partagé du Pixel de développement. Un build lié à un dossier appareil refuse le repli implicite vers cet inventaire.\n- Transfert SSH contrôlé : vérification de l’identité et du stockage, contrôle des empreintes et création des fichiers sans remplacer les données existantes. Aucun formatage, flash ou redémarrage dans l’outil de transfert.\n- Construction et transfert testés sur le Pixel. Premier boot RAM : onze contrôles Gaia et dix contrôles natifs réussis ; clavier et notifications réussis. Le contrôle caméra a rencontré un délai d’autofocus dépassé, conservé dans le rapport.\n- Second boot de la même image : réglage Gaia conservé, onze contrôles Gaia et dix contrôles natifs réussis ; caméra, clavier et notifications réussis. Le timeout autofocus du premier essai reste documenté, pas déclaré corrigé.\n- Retour au build installé vérifié : démarrage Gaia réussi et partition de boot inchangée. Un nouveau transfert refuse bien de remplacer les données déjà utilisées.\n- Provenance du HAL précisée : paquet et recette de téléchargement identifiés, recettes UBports historiques récupérées. La correspondance exacte avec l’ancien artefact Android et son manifeste de sources reste à établir.\n- Prise en charge toujours limitée au noyau qualifié et au stockage ext4/LVM compatible. Ce travail ne fournit pas encore une installation directe depuis Android d’origine.\n\n### PC / Android · APK\n\n- Aucun changement d’interface ni nouvelle compilation dans cette étape.",
"en": "## In development — 3 October 2026 — Per-device Sargo installation\n\n### Tundra · Sargo\n\n- Read-only collection of six boot partitions and existing storage, bound to the phone identity and slot A. Backups remain private.\n- Portable device workspace: RAM builds, boot checks, installation and recovery export can use it without the development Pixel’s shared inventory. Workspace-bound builds reject implicit fallback to that inventory.\n- Checked SSH transfers verify identity, storage and checksums and create files without replacing existing data. The transfer tool never formats, flashes or reboots.\n- Build and transfer tested on the Pixel. First RAM boot passed eleven Gaia and ten native checks; keyboard and notifications passed. Camera testing encountered an autofocus timeout, retained in the report.\n- Second boot of the same image retained the Gaia setting and passed eleven Gaia and ten native checks; camera, keyboard and notifications passed. The first autofocus timeout remains documented, not declared fixed.\n- Verify return to the installed build: Gaia starts and the boot partition is unchanged. A repeated transfer correctly refuses to replace existing data.\n- Refine HAL provenance: identify the installed package and download recipe and retrieve historical UBports build recipes. Exact correspondence with the old Android artifact and resolved source manifest remains unverified.\n- Still limited to the qualified kernel and compatible ext4/LVM storage. This does not yet provide direct installation from stock Android.\n\n### Desktop / Android · APK\n\n- No interface changes or new build in this step."
}
},
{ {
"id": "2026-10-03-sargo-installation-tools", "id": "2026-10-03-sargo-installation-tools",
"text": { "text": {

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 # SPDX-License-Identifier: MPL-2.0
import importlib.util import importlib.util
import json
from pathlib import Path from pathlib import Path
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1] ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools')) sys.path.insert(0,str(ROOT/'tools'))
@ -32,5 +34,13 @@ class SourceSnapshotTests(unittest.TestCase):
self.build['sources']={'../../outside':'hash'} self.build['sources']={'../../outside':'hash'}
with self.assertRaises(ValueError):rescue.verify_sources(self.folder,self.build) 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() 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: patch.object(install,'load',return_value=boot),patch.object(install,'flash_boot_a') as flash:
install.main();flash.assert_not_called() 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): def test_restore_does_not_require_working_candidate(self):
backup=self.folder/'boot_a.img';backup.write_bytes(b'original') backup=self.folder/'boot_a.img';backup.write_bytes(b'original')
boot=SimpleNamespace(checked_backup_plan=lambda:{'serialSha256':'serial','backupDirectory':str(self.folder)}, 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=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output',type=Path,required=True) parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--layout',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('--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('--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') 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') raise ValueError('Data image belongs to another system image')
os.umask(0o077) os.umask(0o077)
built_at=int(time.time()) 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()) 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) storage_table = mount_table(layout)
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
source=Path(plan['backupDirectory'])/'boot_a.img' source=Path(plan['backupDirectory'])/'boot_a.img'
packager=checked_source('aosp-mkbootimg') packager=checked_source('aosp-mkbootimg')
unpack=out/'original' unpack=out/'original'
@ -106,6 +114,8 @@ def main():
shlex.quote(storage_table).encode()) shlex.quote(storage_table).encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py', sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py',
ROOT/'tools/storage_layout.py'] ROOT/'tools/storage_layout.py']
if args.device_workspace:
sources += [ROOT/'tools/sargo_workspace.py', ROOT/'tools/boot_backup.py']
if state: if state:
# Keep the existing layout intact; only /tundra/data/<UUID>.ext4 is used # Keep the existing layout intact; only /tundra/data/<UUID>.ext4 is used
# for Tundra state. Opening its containing filesystem RW is necessary. # 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'), 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), '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, '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, '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}, '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()}, '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,8 +54,13 @@ def export(plan, output):
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True) parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--device-workspace', type=Path)
args = parser.parse_args() args = parser.parse_args()
os.umask(0o077) os.umask(0o077)
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() plan = load('backup_plan', ROOT/'tools/test-sargo-boot.py').checked_backup_plan()
print(export(plan, args.output)) print(export(plan, args.output))
print('Keep this private kit on another disk. Its boot image belongs only to this phone.') 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(): def main():
parser=argparse.ArgumentParser(description=__doc__) parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True) parser.add_argument('--build',type=Path,required=True)
parser.add_argument('--device-workspace',type=Path)
action=parser.add_mutually_exclusive_group() 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('--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') 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) args=parser.parse_args();os.umask(0o077)
boot=load('sargo_boot',ROOT/'tools/test-sargo-boot.py') 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: if args.restore:
# Recovery must still work if the candidate or its proof was damaged. # 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' image=Path(plan['backupDirectory'])/'boot_a.img'
build={'serialSha256':plan['serialSha256']} build={'serialSha256':plan['serialSha256']}
else: else:
plan,_=boot.checked_plan()
build,qualification,image=checked_build(args.build) 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') if plan['serialSha256']!=build['serialSha256']:raise ValueError('Build targets another phone')
print('Private Sargo installation plan') print('Private Sargo installation plan')
print('Boot image:',image) 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(): 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) 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']: if build['imageSha256']!=digest(image) or build['serialSha256']!=plan['serialSha256'] or not build['roundtripPassed']:
raise ValueError('Image or target provenance mismatch') raise ValueError('Image or target provenance mismatch')
verify_sources(folder,build) verify_sources(folder,build)