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