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
|
|
@ -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