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:
parent
d04824ec89
commit
0041cd9660
101 changed files with 3883 additions and 335 deletions
|
|
@ -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']
|
||||
|
|
|
|||
146
tundra/tools/build-standalone-boot.py
Normal file
146
tundra/tools/build-standalone-boot.py
Normal 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()
|
||||
83
tundra/tools/build-standalone-volume.py
Normal file
83
tundra/tools/build-standalone-volume.py
Normal 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()
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
116
tundra/tools/flash-standalone-sargo.py
Normal file
116
tundra/tools/flash-standalone-sargo.py
Normal 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()
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
63
tundra/tools/package-standalone-sargo.py
Normal file
63
tundra/tools/package-standalone-sargo.py
Normal 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()
|
||||
83
tundra/tools/restore-standalone-sargo.py
Normal file
83
tundra/tools/restore-standalone-sargo.py
Normal 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()
|
||||
25
tundra/tools/sparse_backup.py
Normal file
25
tundra/tools/sparse_backup.py
Normal 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
|
||||
35
tundra/tools/standalone_volume.py
Normal file
35
tundra/tools/standalone_volume.py
Normal 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
|
||||
Loading…
Reference in a new issue