Update shared Gaia workflows and Sargo development tooling

Fix camera switching and Contacts/SMS activity routing across shared hosts.
Integrate Marketplace installation and native standalone recovery tools,
Bluetooth runtime packages and trusted certificates.

Update English/French READMEs and the shared changelog with tested scope
and remaining radio/audio/Bluetooth limitations. Preview17 remains the
existing APK; no new native image is released.

Validation: shared JavaScript and native Python tests; Android packaging
and payload parity from an isolated publication checkout.
This commit is contained in:
OverSu 2026-10-08 14:27:17 +02:00
commit 0041cd9660
101 changed files with 3883 additions and 335 deletions

View file

@ -184,9 +184,19 @@ def main():
buffer = io.BytesIO()
selected = [*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
ROOT.parent/'tools/installed_apps.py',
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
ROOT.parent/'tools/project.py',
ROOT.parent/'tools/serve-gaia.py',
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a raw-userdata Tundra boot, or its nondestructive container test variant."""
import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
from ramdisk_reference import read_newc
from standalone_volume import checked_volume
from tundra import ROOT, checked_source, cpio, digest
def storage_scripts(init, native, volume, trial):
start = init.index(b'# Use the inventoried filesystem')
end = init.index(b'echo TUNDRA_RESCUE_READY', start)
legacy = init[start:end]
# The container test keeps the old mount solely as a carrier for the image.
if trial:
legacy += b'mkdir -p /run/legacy-storage\nmount --move /installed /run/legacy-storage\n'
else:
legacy = b'# Standalone boot does not open the previous operating system.\n'
init = init[:start]+legacy+init[end:]
init = init.replace(b'exec /bin/sh /native-root.sh', b'exec /bin/sh /standalone-mount.sh')
native = native.replace(b'LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root',
b': "${SYSTEM_IMAGE:?A bundled system image is required}"\nLOWER=/run/tundra-system')
native = native.replace(b'/installed', b'/volume')
native = native.replace(b'/reference/volume', b'/reference/tundra-volume')
if volume.get('hardwareImages'):
lines = ['mkdir -p /newroot/reference']
for name in ('vendor.img', 'modem.img'):
lines += ['test -s /volume/tundra/hardware/'+name,
': > /newroot/reference/'+name,
'mount --bind /volume/tundra/hardware/'+name+' /newroot/reference/'+name,
'mount -o remount,bind,ro /newroot/reference/'+name]
config = json.dumps({'images':volume['hardwareImages']}, separators=(',',':'))
lines += ['mkdir -p /newroot/etc/tundra',
'printf \'%s\\n\' '+shlex.quote(config)+' > /newroot/etc/tundra/hardware-images.json']
native = native.replace(b'# Installed lower layers', ('\n'.join(lines)+'\n# Installed lower layers').encode())
if not trial:
if b'/dev/mapper' in init or b'EXPECTED_STORAGE_HASH' in init:
# The old expected hash assignment is unnecessary in the raw image.
init = b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'EXPECTED_STORAGE_HASH='))
if b'/dev/mapper' in init or b'/installed/' in native:
raise ValueError('Legacy storage leaked into standalone boot')
device = '/run/legacy-storage/tundra/standalone/'+volume['uuid']+'.ext4' if trial else '/dev/mmcblk0p72'
config = 'VOLUME_DEVICE='+shlex.quote(device)+'\nVOLUME_UUID='+shlex.quote(volume['uuid'])+'\nVOLUME_OPTIONS='+('loop,rw' if trial else 'rw')+'\n'
mount = (ROOT/'boot/standalone-mount.sh').read_bytes().replace(b'# VOLUME_CONFIGURATION', config.encode())
return init, native, mount
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--volume', type=Path, required=True)
parser.add_argument('--prepared', type=Path, required=True)
parser.add_argument('--device-workspace', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--container-trial', action='store_true')
parser.add_argument('--no-deadline', action='store_true')
parser.add_argument('--boot-control', type=Path, help='Pinned ARM64 qbootctl binary')
parser.add_argument('--no-maintenance', action='store_true', help='Exclude private USB SSH credentials and server')
parser.add_argument('--local-debugger', action='store_true', help='Private RAM tests: loopback Marionette, never a public image')
args = parser.parse_args(); os.umask(0o077)
if args.local_debugger and args.no_maintenance: parser.error('Local debugger requires a private maintenance image')
volume = checked_volume(args.volume)
if args.no_deadline and not args.container_trial and not args.boot_control:
parser.error('Persistent standalone images require --boot-control')
if args.boot_control and digest(args.boot_control) != 'dcb00e4c4f038071a5d9979263128325e16b848903371f6c00d49cf3e25c34d9':
raise ValueError('Unqualified boot-control binary')
if args.boot_control and digest(args.boot_control.with_name('copyright')) != '8969e659aff7ed5bb3249341764c2bd048d5abe7400076f01485650e727e8c42':
raise ValueError('Boot-control license notice missing or changed')
system = json.loads((args.prepared/'system.json').read_text())
state = json.loads((args.prepared/'data/state.json').read_text())
if volume['systemImageSha256'] != system['imageSha256'] or volume['stateUuid'] != state['uuid']:
raise ValueError('Volume does not contain this system/data pair')
command = [sys.executable,str(ROOT/'tools/build-rescue-boot.py'), '--output',str(args.output),
'--device-workspace',str(args.device_workspace),'--layout',str(args.device_workspace/'layout.json'),
'--native','--runtime-config','--network','--hardware',
'--system-image',str(args.prepared/'system.json'),'--state',str(args.prepared/'data/state.json')]
if args.no_deadline: command.append('--no-deadline')
if args.local_debugger: command.append('--local-debugger')
subprocess.run(command,check=True)
out = args.output.resolve(); build = json.loads((out/'build.json').read_text())
ramdisk = out/'initramfs.cpio.gz'; files = read_newc(ramdisk.read_bytes())
init,native,mount = storage_scripts(files['init']['data'],files['native-root.sh']['data'],volume,args.container_trial)
if args.boot_control:
files['sbin/tundra-qbootctl']={'mode':stat.S_IFREG|0o755,'data':args.boot_control.read_bytes()}
files['qbootctl-copyright']={'mode':stat.S_IFREG|0o644,'data':args.boot_control.with_name('copyright').read_bytes()}
files['standalone-boot-success.py']={'mode':stat.S_IFREG|0o755,'data':(ROOT/'runtime/mark-boot-success.py').read_bytes()}
unit=b'[Unit]\nDescription=Acknowledge a healthy standalone Vulpes boot\nAfter=tundra-display.service\n[Service]\nType=oneshot\nTimeoutStartSec=650\nExecStart=/usr/bin/python3 /usr/libexec/tundra-mark-boot-success.py\n'
files['standalone-boot-success.service']={'mode':stat.S_IFREG|0o644,'data':unit}
setup=b"""mkdir -p /newroot/usr/libexec /newroot/etc/systemd/system/tundra.target.wants
mkdir -p /newroot/usr/share/doc/qbootctl
cp /qbootctl-copyright /newroot/usr/share/doc/qbootctl/copyright
cp /sbin/tundra-qbootctl /newroot/usr/libexec/tundra-qbootctl
cp /standalone-boot-success.py /newroot/usr/libexec/tundra-mark-boot-success.py
cp /standalone-boot-success.service /newroot/etc/systemd/system/tundra-boot-success.service
ln -sf ../tundra-boot-success.service /newroot/etc/systemd/system/tundra.target.wants/tundra-boot-success.service
"""
native=native.replace(b'# Installed lower layers',setup+b'# Installed lower layers')
if args.no_maintenance:
init=b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'/sbin/dropbear '))
init=init.replace(b'TUNDRA_USB_SSH_READY',b'TUNDRA_USB_READY')
start=native.index(b'# Keep a minimal, already tested rescue environment')
end=native.index(b'for fs in dev proc sys run;',start)
native=native[:start]+b'# Public boot: no USB maintenance account or shared SSH key.\n'+native[end:]
for name in ('etc/tundra-hostkey','root/.ssh/authorized_keys','sbin/dropbear'):
files.pop(name,None)
files['init']['data']=init; files['native-root.sh']['data']=native
files['standalone-mount.sh']={'mode':stat.S_IFREG|0o755,'data':mount}
files['volume']={'mode':stat.S_IFDIR|0o755,'data':b''}
entries=[]
for name, item in sorted(files.items()):
entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
ramdisk.write_bytes(cpio(entries))
packager=checked_source('aosp-mkbootimg'); original=out/build['image']
with tempfile.TemporaryDirectory() as temp:
unpack=Path(temp)/'unpack'
text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(original),'--out',str(unpack),'--format=mkbootimg'],text=True)
options=shlex.split(text); fields=dict(zip(options[::2],options[1::2]));fields['--ramdisk']=str(ramdisk)
image=out/'boot.img'; command=[sys.executable,str(packager/'mkbootimg.py')]
for key,value in fields.items():command.extend([key,value])
subprocess.run(command+['-o',str(image)],check=True)
verify=Path(temp)/'verify'
subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
if digest(verify/'kernel')!=build['kernelSha256'] or digest(verify/'ramdisk')!=digest(ramdisk):
raise ValueError('Standalone boot roundtrip failed')
original.unlink()
for path in (Path(__file__).resolve(),ROOT/'tools/standalone_volume.py',ROOT/'boot/standalone-mount.sh',ROOT/'runtime/mark-boot-success.py'):
relative=str(path.relative_to(ROOT)); target=out/'sources/tundra'/relative
target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target);build['sources'][relative]=digest(path)
build.update(image='boot.img',imageSha256=digest(image),ramdiskSha256=digest(ramdisk),
storageMode='container-test' if args.container_trial else 'raw-userdata-ext4',
standaloneVolume=volume,flashReady=False,bootControlSha256=digest(args.boot_control) if args.boot_control else None,
containsPrivateTrialHostKey=not args.no_maintenance,maintenanceEnabled=not args.no_maintenance,
scope='Standalone Tundra boot candidate; hardware qualification required')
(out/'build.json').write_text(json.dumps(build,indent=2)+'\n')
print(image)
if __name__=='__main__':main()

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a fresh ext4 userdata image. No connection to a phone or partition writes."""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import uuid
from persistent_state import FEATURES
from tundra import digest
from standalone_volume import checked_volume
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepared', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
parser.add_argument('--hardware', type=Path, help='Locally supplied pinned vendor/modem images')
parser.add_argument('--size-mib', type=int, default=8192)
args = parser.parse_args()
if not 5120 <= args.size_mib <= 32768:
parser.error('Choose 5120–32768 MiB')
spec = importlib.util.spec_from_file_location('stage', Path(__file__).with_name('stage-sargo-files.py'))
stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
files = stage.inputs(args.prepared)
hardware = {}
if args.hardware:
lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
hardware = lock['images']
for name, record in hardware.items():
source = args.hardware/record['source']
if source.is_symlink() or source.stat().st_size != record['bytes'] or digest(source) != record['sha256']:
raise ValueError('Hardware image changed: '+name)
payload_bytes = sum(p.stat().st_size for _,_,p,_ in files)
payload_bytes += sum(record['bytes'] for record in hardware.values())
if args.size_mib*1024**2 < payload_bytes+512*1024**2:
raise ValueError('Insufficient space for payload and filesystem metadata')
os.umask(0o077)
out = args.output.resolve(); out.mkdir(parents=True, exist_ok=False)
(out/'.incomplete').touch()
seed = out/'seed'; seed.mkdir()
for kind, name, source, record in files:
target = seed/'tundra'/kind/name; target.parent.mkdir(parents=True, exist_ok=True)
# mke2fs reads the file; a hard link avoids another multi-gigabyte copy.
os.link(source, target)
if hardware:
directory = seed/'tundra/hardware'; directory.mkdir()
for name, record in hardware.items():
os.link(args.hardware/record['source'], directory/name)
system = json.loads((args.prepared/'system.json').read_text())
state = json.loads((args.prepared/'data/state.json').read_text())
identifier = str(uuid.uuid4())
(seed/'tundra-volume-id').write_text(identifier+'\n')
image = out/'userdata.ext4'
with image.open('xb') as f: f.truncate(args.size_mib*1024**2)
subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-I','256','-O',FEATURES,
'-U',identifier,'-L','TUNDRA_VOLUME','-d',str(seed),str(image)],check=True)
names=['/tundra-volume-id','/tundra','/tundra/images','/tundra/data']
names += ['/tundra/'+kind+'/'+name for kind,name,_,_ in files]
if hardware:
names += ['/tundra/hardware', *['/tundra/hardware/'+name for name in hardware]]
for name in names:
for field in ('uid','gid'):
subprocess.run(['debugfs','-w','-R',f'set_inode_field {name} {field} 0',str(image)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
result = subprocess.run(['e2fsck','-f','-n',str(image)],capture_output=True,text=True)
(out/'filesystem-check.txt').write_text(result.stdout+result.stderr)
if result.returncode: raise ValueError('Standalone filesystem check failed')
manifest={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':image.name,
'uuid':identifier,'bytes':image.stat().st_size,'imageSha256':digest(image),
'systemImageSha256':system['imageSha256'],'stateUuid':state['uuid'],
'filesystemCheckPassed':True,'flashReady':False,'hardwareImages':hardware,
'scope':'Fresh standalone storage; flashing userdata destroys previous user data'}
(out/'volume.json').write_text(json.dumps(manifest,indent=2)+'\n')
checked_volume(out/'volume.json')
(out/'.incomplete').unlink()
print(out/'volume.json')
if __name__ == '__main__': main()

View file

@ -128,7 +128,7 @@ def main():
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests', 'logs', 'profiles'):
(product/name).mkdir(exist_ok=True)
for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
for name in ('serve-gaia.py', 'project.py', 'run-host.py', 'installed_apps.py'):
shutil.copy2(PROJECT/'tools'/name, product/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', product/'release.json')

View file

@ -0,0 +1,116 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Install a standalone Sargo package using Fastboot only (Python 3, any host OS).
Default: verify files and display the destructive installation plan.
--install --erase-userdata replaces the old OS and ALL files in userdata.
Keep a separate backup. The unlocked bootloader and device calibration remain.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
import struct
import subprocess
import sys
PARTITIONS=('userdata','dtbo_a','vbmeta_a','boot_a')
NAMES={'userdata':'userdata.img','dtbo_a':'dtbo.img','vbmeta_a':'vbmeta.img','boot_a':'boot.img'}
def digest(path):
sha=hashlib.sha256()
with path.open('rb') as source:
while block:=source.read(1024*1024):sha.update(block)
return sha.hexdigest()
def sparse_size(path):
with path.open('rb') as source:header=source.read(28)
if len(header)!=28:raise ValueError('Truncated userdata image')
magic,major,minor,file_header,chunk_header,block_size,blocks,chunks,crc=struct.unpack('<I4H4I',header)
if magic!=0xed26ff3a or major!=1 or file_header!=28 or chunk_header!=12 or block_size!=4096 or not chunks:
raise ValueError('Expected an Android sparse ext4 image')
return block_size*blocks
def package(folder):
folder=Path(folder).resolve()
if (folder/'.incomplete').exists():raise ValueError('Package construction is incomplete')
value=json.loads((folder/'package.json').read_text())
if value.get('schema')!=1 or value.get('device')!='sargo' or value.get('storage')!='raw-userdata-ext4':
raise ValueError('Wrong package model or storage format')
if set(value.get('partitions',{}))!=set(PARTITIONS):raise ValueError('Unexpected partition write plan')
for partition,record in value['partitions'].items():
if record.get('file')!=NAMES[partition]:raise ValueError('Unexpected image filename')
path=folder/record['file']
if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:
raise ValueError('Image checksum or size mismatch: '+partition)
if sparse_size(folder/'userdata.img')!=value['userdataBytes']:raise ValueError('Sparse userdata geometry changed')
return value
def getvar(fastboot,serial,name):
result=subprocess.run([fastboot,'-s',serial,'getvar',name],capture_output=True,text=True,check=True,timeout=15)
for line in (result.stdout+'\n'+result.stderr).splitlines():
match=re.fullmatch(r'(?:\(bootloader\)\s*)?'+re.escape(name)+r':\s*(.+)',line.strip())
if match:return match[1].strip()
raise ValueError('Missing Fastboot variable: '+name)
def preflight(fastboot,serial,value):
info={name:getvar(fastboot,serial,name) for name in ('product','unlocked','current-slot')}
if info['product']!='sargo' or info['unlocked']!='yes' or info['current-slot'] not in ('a','b'):
raise ValueError('Requires an unlocked Pixel 3a (sargo), not Pixel 3a XL')
for partition in PARTITIONS:
size=int(getvar(fastboot,serial,'partition-size:'+partition),16)
needed=value['userdataBytes'] if partition=='userdata' else value['partitions'][partition]['bytes']
if needed>size:raise ValueError('Image exceeds partition: '+partition)
return info
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--package',type=Path,default=Path(__file__).resolve().parent)
parser.add_argument('--serial')
parser.add_argument('--install',action='store_true')
parser.add_argument('--erase-userdata',action='store_true')
parser.add_argument('--experimental',action='store_true')
parser.add_argument('--reboot',action='store_true')
args=parser.parse_args();value=package(args.package)
print('Pixel 3a / Sargo: replace userdata, dtbo_a, vbmeta_a and boot_a; activate slot A.')
print('ALL existing user data and the old OS in userdata will be lost. Bootloader remains unlocked.')
print('No existing Android or Droidian installation is required.')
if not args.install:
print('Review only; no device command sent.');return
if not args.erase_userdata:parser.error('--install requires --erase-userdata')
if not value.get('flashQualified') and not args.experimental:
parser.error('This candidate is not qualified; private tests require --experimental')
fastboot=shutil.which('fastboot')
if not fastboot:raise ValueError('Install Android platform-tools (fastboot) on the host computer')
devices=subprocess.check_output([fastboot,'devices'],text=True,timeout=10).splitlines()
serials=[line.split()[0] for line in devices if len(line.split())==2 and line.split()[1]=='fastboot']
serial=args.serial
if serial is None:
if len(serials)!=1:raise ValueError('Connect exactly one Pixel or specify --serial')
serial=serials[0]
if serial not in serials:raise ValueError('Selected device not in Fastboot')
info=preflight(fastboot,serial,value)
report={'deviceSha256':hashlib.sha256(serial.encode()).hexdigest(),'preflight':info,'completed':[],'passed':False}
path=args.package/'install-report.json'
def save():path.write_text(json.dumps(report,indent=2)+'\n')
save()
subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
report['completed'].append('erase:userdata');save()
for partition in PARTITIONS:
subprocess.run([fastboot,'-s',serial,'flash',partition,str((args.package/NAMES[partition]).resolve())],check=True,timeout=1200)
report['completed'].append(partition);save()
subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
report['passed']=True;save()
if args.reboot:subprocess.run([fastboot,'-s',serial,'reboot'],check=True,timeout=30)
print('Images written. Keep the bootloader unlocked. Select Start to launch Vulpes.')
if __name__=='__main__':main()

View file

@ -23,6 +23,8 @@ def load(name, path):
def checked_build(folder):
folder=Path(folder).resolve()
build=json.loads((folder/'build.json').read_text())
if build.get('storageMode') in ('raw-userdata-ext4', 'container-test'):
raise ValueError('Use the standalone installer; the old boot-only plan is incompatible')
if build.get('localDebugger'):raise ValueError('Diagnostic debugger image cannot be flashed')
qualification=json.loads((folder/'qualification.json').read_text())
if (not qualification.get('passed') or not qualification.get('privateInstallReady')

View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Package boot, fresh userdata and boot metadata for a standalone installation.
Builds local artifacts only. A candidate is never marked flash-qualified here.
Private development credentials, if present in boot, prohibit redistribution.
"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
from standalone_volume import checked_volume
from tundra import ROOT,digest
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',required=True,type=Path)
parser.add_argument('--volume',required=True,type=Path)
parser.add_argument('--dtbo',required=True,type=Path)
parser.add_argument('--output',required=True,type=Path)
args=parser.parse_args();os.umask(0o077)
volume=checked_volume(args.volume);build=json.loads((args.build/'build.json').read_text())
if build.get('storageMode')!='raw-userdata-ext4' or build.get('watchdogSeconds') is not None or build.get('localDebugger'):
raise ValueError('Expected a persistent standalone boot without debugger or timed reboot')
if build['standaloneVolume']!=volume or not volume.get('hardwareImages'):
raise ValueError('Boot must match a volume with bundled vendor/modem images')
boot=args.build/build['image']
if digest(boot)!=build['imageSha256']:raise ValueError('Boot image changed')
spec=importlib.util.spec_from_file_location('sources',ROOT/'tools/test-rescue-boot.py')
sources=importlib.util.module_from_spec(spec);spec.loader.exec_module(sources);sources.verify_sources(args.build,build)
lock=json.loads((ROOT/'manifests/sargo-hardware.json').read_text())
if args.dtbo.stat().st_size!=lock['dtbo']['bytes'] or digest(args.dtbo)!=lock['dtbo']['sha256']:
raise ValueError('Unqualified device-tree overlay')
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
(out/'.incomplete').touch()
shutil.copyfile(boot,out/'boot.img');shutil.copyfile(args.dtbo,out/'dtbo.img')
subprocess.run(['avbtool','make_vbmeta_image','--flags','3','--padding_size','4096','--algorithm','NONE','--output',str(out/'vbmeta.img')],check=True)
subprocess.run(['img2simg',str(args.volume.parent/volume['image']),str(out/'userdata.img')],check=True)
# Roundtrip verifies the actual sparse payload, not only its header.
restored=out/'userdata-roundtrip.ext4'
subprocess.run(['simg2img',str(out/'userdata.img'),str(restored)],check=True)
if digest(restored)!=volume['imageSha256']:raise ValueError('Sparse image roundtrip mismatch')
restored.unlink()
files={name:{'file':file,'bytes':(out/file).stat().st_size,'sha256':digest(out/file)}
for name,file in [('boot_a','boot.img'),('userdata','userdata.img'),('dtbo_a','dtbo.img'),('vbmeta_a','vbmeta.img')]}
value={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','partitions':files,
'userdataBytes':volume['bytes'],'userdataRawSha256':volume['imageSha256'],
'volumeUuid':volume['uuid'],'systemImageSha256':volume['systemImageSha256'],
'flashQualified':False,'containsPrivateMaintenanceKey':bool(build.get('containsPrivateTrialHostKey')),
'redistributionReady':False,'previousOsRequired':False,
'requirements':['Unlocked Pixel 3a (sargo)','Original device calibration partitions retained'],
'scope':'Experimental standalone installation candidate; userdata is replaced in full'}
(out/'package.json').write_text(json.dumps(value,indent=2)+'\n')
shutil.copyfile(ROOT/'tools/flash-standalone-sargo.py',out/'flash-standalone-sargo.py')
(out/'SHA256SUMS').write_text(''.join(digest(p)+' '+p.name+'\n' for p in sorted(out.iterdir()) if p.is_file() and p.name not in ('.incomplete','SHA256SUMS')))
(out/'.incomplete').unlink();print(out/'package.json')
if __name__=='__main__':main()

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Restore a private full-storage backup after a standalone Sargo test.
Default: verify the archive and partition backups only. --restore overwrites
userdata and the three slot-A boot partitions. Backups are device-specific.
"""
import argparse
import gzip
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
def digest(path):
sha=hashlib.sha256()
with path.open('rb') as source:
while block:=source.read(4*1024**2):sha.update(block)
return sha.hexdigest()
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--backup',required=True,type=Path)
parser.add_argument('--restore',action='store_true')
args=parser.parse_args();folder=args.backup.resolve()
manifest=json.loads((folder/'recovery.json').read_text())
if manifest.get('device')!='sargo' or manifest.get('activeSlot')!='a':raise ValueError('Wrong recovery target')
data=json.loads((folder/'userdata.json').read_text())
if not data.get('checked') or data.get('archive')!='userdata-original.raw.gz':raise ValueError('Unverified userdata backup')
if not data.get('sourceWasUnmounted') or manifest.get('userdataSha256')!=data['sha256'] or manifest.get('userdataBytes')!=data['bytes']:
raise ValueError('Storage backup is not bound to this recovery kit')
for name,record in manifest['files'].items():
if name not in ('boot_a.img','dtbo_a.img','vbmeta_a.img'):raise ValueError('Unexpected recovery partition')
path=folder/name
if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:raise ValueError('Partition backup changed: '+name)
if set(manifest['files'])!={'boot_a.img','dtbo_a.img','vbmeta_a.img'}:raise ValueError('Incomplete boot backup set')
archive=folder/data['archive'];sha=hashlib.sha256();count=0
with gzip.open(archive,'rb') as source:
while block:=source.read(4*1024**2):
count+=len(block)
if count>data['bytes']:raise ValueError('Oversized backup')
sha.update(block)
if count!=data['bytes'] or sha.hexdigest()!=data['sha256']:raise ValueError('Storage backup changed')
print('Private boot and full userdata backups verified.')
if not args.restore:
print('No phone action. --restore replaces its current userdata.');return
fastboot=shutil.which('fastboot')
if not fastboot:raise ValueError('Fastboot is required')
serials=[line.split()[0] for line in subprocess.check_output([fastboot,'devices'],text=True).splitlines() if len(line.split())==2]
matches=[serial for serial in serials if hashlib.sha256(serial.encode()).hexdigest()==manifest['serialSha256']]
if len(matches)!=1:raise ValueError('Original phone not present in Fastboot')
serial=matches[0]
# Use the standalone installer's strict Fastboot variable parser.
import importlib.util
spec=importlib.util.spec_from_file_location('flash',Path(__file__).with_name('flash-standalone-sargo.py'))
flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
if flash.getvar(fastboot,serial,'product')!='sargo' or flash.getvar(fastboot,serial,'unlocked')!='yes':raise ValueError('Wrong or locked phone')
if int(flash.getvar(fastboot,serial,'partition-size:userdata'),16)!=data['bytes']:raise ValueError('Storage geometry changed')
raw=folder/'userdata-restore.raw'
if raw.exists():
if raw.stat().st_size!=data['bytes'] or digest(raw)!=data['sha256']:raise ValueError('Existing restoration file changed')
else:
if shutil.disk_usage(folder).free<data['bytes']+1024**3:raise ValueError('Not enough host space to decompress recovery image')
partial=folder/'userdata-restore.partial'
with gzip.open(archive,'rb') as source,partial.open('xb') as target:
while block:=source.read(4*1024**2):
if not block.strip(b'\0'):target.seek(len(block),1)
else:target.write(block)
target.truncate(data['bytes'])
if digest(partial)!=data['sha256']:raise ValueError('Decompressed backup changed')
partial.rename(raw)
subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
subprocess.run([fastboot,'-s',serial,'-S','256M','flash','userdata',str(raw)],check=True,timeout=7200)
for name in ('dtbo_a.img','vbmeta_a.img','boot_a.img'):
subprocess.run([fastboot,'-s',serial,'flash',name.removesuffix('.img'),str(folder/name)],check=True,timeout=120)
subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
print('Original userdata and boot restored. Select Start; bootloader remains unlocked.')
if __name__=='__main__':main()

View file

@ -0,0 +1,25 @@
# SPDX-License-Identifier: MPL-2.0
"""Decode the read-only TUNRAW1 stream; reject truncation and overlapping blocks."""
import struct
def exact(stream, length):
data=bytearray()
while len(data)<length:
block=stream.read(length-len(data))
if not block:raise ValueError('Truncated backup stream')
data.extend(block)
return bytes(data)
def receive(stream, target, expected_size):
if exact(stream,8)!=b'TUNRAW1\0':raise ValueError('Unexpected backup protocol')
end=0
while True:
offset,length=struct.unpack('<QQ',exact(stream,16))
if not length:
if offset!=expected_size or stream.read(1):raise ValueError('Backup size or trailer mismatch')
target.truncate(expected_size)
return
if offset<end or length>65536 or offset+length>expected_size:raise ValueError('Invalid backup extent')
target.seek(offset);target.write(exact(stream,length));end=offset+length

View file

@ -0,0 +1,35 @@
# SPDX-License-Identifier: MPL-2.0
"""Validate the fresh userdata filesystem used by standalone Sargo images."""
import json
from pathlib import Path
import re
import uuid
from tundra import digest
def checked_volume(path, verify_image=True):
path = Path(path)
value = json.loads(path.read_text())
if (value.get('schema') != 1 or value.get('device') != 'sargo'
or value.get('storage') != 'raw-userdata-ext4'
or value.get('image') != 'userdata.ext4'
or value.get('filesystemCheckPassed') is not True):
raise ValueError('Expected a checked standalone Sargo volume')
identifier = value.get('uuid')
if not isinstance(identifier, str) or str(uuid.UUID(identifier)) != identifier:
raise ValueError('Invalid volume UUID')
for key in ('imageSha256', 'systemImageSha256'):
if not re.fullmatch(r'[a-f0-9]{64}', value.get(key, '')):
raise ValueError('Invalid '+key)
if type(value.get('bytes')) is not int or not 5*1024**3 <= value['bytes'] <= 32*1024**3:
raise ValueError('Invalid standalone filesystem size')
hardware = value.get('hardwareImages', {})
if hardware:
lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
if hardware != lock['images']:
raise ValueError('Unqualified standalone hardware images')
if verify_image:
image = path.parent/value['image']
if image.is_symlink() or image.stat().st_size != value['bytes'] or digest(image) != value['imageSha256']:
raise ValueError('Standalone volume changed')
return value