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:
parent
978542bc87
commit
d04824ec89
16 changed files with 707 additions and 7 deletions
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
- **Une base Gaia :** interface et services communs pour PC, Android et Tundra.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
- **One Gaia base:** interface and common services serve desktop, Android and Tundra.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
"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",
|
||||
"text": {
|
||||
|
|
|
|||
70
tundra/manifests/android-hal.json
Normal file
70
tundra/manifests/android-hal.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
113
tundra/tests/test_device_workspace.py
Normal file
113
tundra/tests/test_device_workspace.py
Normal 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()
|
||||
|
|
@ -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)},
|
||||
|
|
|
|||
91
tundra/tests/test_stage_files.py
Normal file
91
tundra/tests/test_stage_files.py
Normal 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()
|
||||
|
|
@ -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()},
|
||||
|
|
|
|||
80
tundra/tools/collect-sargo-installation.py
Normal file
80
tundra/tools/collect-sargo-installation.py
Normal 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))
|
||||
|
|
@ -54,8 +54,13 @@ 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)
|
||||
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.')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
67
tundra/tools/prepare-sargo-device.py
Normal file
67
tundra/tools/prepare-sargo-device.py
Normal 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()
|
||||
70
tundra/tools/sargo_workspace.py
Normal file
70
tundra/tools/sargo_workspace.py
Normal 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
|
||||
138
tundra/tools/stage-sargo-files.py
Normal file
138
tundra/tools/stage-sargo-files.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue