Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Inspect the embedded configuration of our pinned sargo kernel, without a phone."""
import json
from pathlib import Path
import subprocess
import zlib
from tundra import ROOT, digest, now, save
def inspect(path):
# The AOSP artifact appends device trees to an LZ4 stream. lz4 reports trailing
# data, so validate the embedded gzip/config payload rather than its exit code.
result = subprocess.run(['lz4','-dc',str(path)],capture_output=True,check=False)
start=result.stdout.find(b'IKCFG_ST')
if start<0: raise ValueError('Kernel has no embedded IKCONFIG signature')
stream=zlib.decompressobj(31)
raw=stream.decompress(result.stdout[start+8:])
if not stream.eof or not stream.unused_data.startswith(b'IKCFG_ED'):
raise ValueError('Invalid compressed kernel config or terminator')
config=raw.decode()
if not config.startswith('#\n# Automatically generated file; DO NOT EDIT.'):
raise ValueError('Unexpected kernel config format')
values={}
for line in config.splitlines():
if line.startswith('CONFIG_'):
key,value=line.split('=',1);values[key]=value
elif line.startswith('# CONFIG_') and line.endswith(' is not set'):
values[line.split()[1]]='n'
keys=['CONFIG_ARM64','CONFIG_DEVTMPFS','CONFIG_CONFIGFS_FS','CONFIG_USB_DWC3',
'CONFIG_USB_DWC3_MSM','CONFIG_USB_CONFIGFS','CONFIG_USB_CONFIGFS_ACM',
'CONFIG_USB_CONFIGFS_F_FS','CONFIG_PSTORE','CONFIG_PSTORE_RAM','CONFIG_PSTORE_CONSOLE']
return config,{key:values.get(key,'absent') for key in keys}
def main():
# Source layout is owned by the common tooling.
from tundra import locked
commit=locked('aosp-sargo-kernel')['commit']
path=ROOT/'downloads/aosp-sargo-kernel'/commit/'Image.lz4-dtb'
config,features=inspect(path)
folder=ROOT/'out/sargo';folder.mkdir(parents=True,exist_ok=True)
(folder/'kernel.config').write_text(config)
report={'recordedAt':now(),'kernelSha256':digest(path),'sourceCommit':commit,
'features':features,'hardwareTested':False,
'usbDiagnosticReady':False,
'conclusions':['USB FunctionFS is compiled in; an actual userspace service is still needed.',
'USB ACM is not compiled in; a generic ttyGS0 console will not work with this prebuilt.',
'devtmpfs is not compiled in; Android uevent/device-node handling must be provided.',
'pstore support does not prove reserved RAM works on this phone.']}
save(folder/'kernel-audit.json',report)
print(json.dumps(report,indent=2))
if __name__=='__main__': main()

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Back up boot partitions through the existing SSH master; sudo stays in the user's terminal."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
def main():
if not sys.stdin.isatty():
raise RuntimeError('Run in your terminal: sudo may ask for the phone password there')
socket = ROOT / 'logs/device/ssh-control'
spec = importlib.util.spec_from_file_location('collector', ROOT / 'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
base = collector.ssh_base(socket, '10.15.19.82', 'droidian')
subprocess.run(base[:-1] + ['-O', 'check', base[-1]], check=True)
source = (ROOT / 'droidian/backup-boot.py').read_bytes()
sha = hashlib.sha256(source).hexdigest()
remote = '/home/droidian/vulpes-tundra-tests/backup-boot-' + sha[:16] + '.py'
subprocess.run(base + [shlex.join(['python3', '-c',
'import pathlib,sys;pathlib.Path(sys.argv[1]).write_bytes(sys.stdin.buffer.read())', remote])],
input=source, check=True)
# Password entry goes directly to the terminal, never to logs or the assistant.
subprocess.run(base[:-1] + ['-t', base[-1], shlex.join(['sudo', '/usr/bin/python3', '-I', remote])], check=True)
listing = subprocess.check_output(base + [shlex.join(['python3', '-c',
"import pathlib;print('\\n'.join(str(p) for p in sorted(pathlib.Path('/var/tmp').glob('vulpes-boot-backup-*/manifest.json')) if p.is_file()))"])], text=True)
os.umask(0o077)
destination = ROOT.parents[2] / 'backups/tundra/sargo/raw-boot'
for entry in listing.splitlines():
folder = Path(entry).parent
if folder.parent != Path('/var/tmp') or not folder.name.startswith('vulpes-boot-backup-'):
raise RuntimeError('Unexpected remote backup path')
read = lambda p: subprocess.check_output(base + [shlex.join(['cat', str(p)])], timeout=120)
manifest_bytes = read(folder / 'manifest.json')
manifest = json.loads(manifest_bytes)
expected = {'boot_a.img', 'boot_b.img', 'dtbo_a.img', 'dtbo_b.img', 'vbmeta_a.img', 'vbmeta_b.img'}
if manifest.get('device') != 'sargo' or set(manifest.get('files', {})) != expected:
raise RuntimeError('Invalid backup manifest')
local = destination / folder.name
local.mkdir(parents=True, exist_ok=True)
for name, info in manifest['files'].items():
target = local / name
data = target.read_bytes() if target.exists() else read(folder / name)
if len(data) != info['bytes'] or hashlib.sha256(data).hexdigest() != info['sha256']:
raise RuntimeError('Backup checksum mismatch: ' + name)
if not target.exists():
with target.open('xb') as output:
output.write(data)
(local / 'manifest.json').write_bytes(manifest_bytes)
print('Verified on PC:', local)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,26 @@
# SPDX-License-Identifier: MPL-2.0
"""Validate the inventoried Sargo partition set before any boot experiment."""
import json
from pathlib import Path
from tundra import digest
SIZES = {'boot_a.img': 67108864, 'boot_b.img': 67108864,
'dtbo_a.img': 8417280, 'dtbo_b.img': 8417280,
'vbmeta_a.img': 65536, 'vbmeta_b.img': 65536}
def verify(folder):
folder = Path(folder).resolve()
manifest = json.loads((folder / 'manifest.json').read_text())
if manifest.get('device') != 'sargo' or manifest.get('rawPartitions') is not True:
raise ValueError('Expected a raw Sargo partition backup')
if set(manifest.get('files', {})) != set(SIZES):
raise ValueError('Expected exactly boot, DTBO and vbmeta for both slots')
for name, size in SIZES.items():
path = folder / name
record = manifest['files'][name]
if path.is_symlink() or not path.is_file() or path.stat().st_size != size or record['bytes'] != size:
raise ValueError('Unexpected partition file or size: ' + name)
if digest(path) != record['sha256']:
raise ValueError('Partition checksum mismatch: ' + name)
return manifest

View file

@ -0,0 +1,31 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Cross-compile the local, version-pinned GLVND adapter; no system installation."""
import json
from pathlib import Path
import subprocess
from tundra import ROOT, digest, linker, now, save
def main():
source = ROOT/'droidian/graphics/egl-config.c'
folder = ROOT/'out/droidian-graphics'
folder.mkdir(parents=True, exist_ok=True)
target = folder/'libEGL_vulpes_hybris.so'
command = ['clang','--target=aarch64-linux-gnu',f'--ld-path={linker()}',
'-ffreestanding','-fPIC','-shared','-nostdlib','-Os',
'-Wall','-Wextra','-Werror','-I/usr/include',str(source),'-o',str(target)]
subprocess.run(command, check=True)
manifest = json.loads((ROOT/'droidian/graphics/vendor.json').read_text())
manifest.update(adapterSha256=digest(target),sourceSha256=digest(source))
save(folder/'manifest.json',manifest)
save(folder/'build.json',{'recordedAt':now(),'command':command,
'compiler':subprocess.check_output(['clang','--version'],text=True).splitlines()[0],
'headers':{p:digest(Path('/usr/include')/p) for p in
['glvnd/libeglabi.h','glvnd/GLdispatchABI.h','EGL/egl.h','EGL/eglext.h','EGL/eglplatform.h','KHR/khrplatform.h']},
'adapterSha256':digest(target)})
print(target)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,111 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a diagnostic using verified /boot copies. Never writes to a device."""
import argparse
import json
from pathlib import Path
import shlex
import stat
import struct
import subprocess
import sys
import tempfile
from tundra import ROOT, checked_source, cpio, digest, linker, now, save
from boot_backup import verify as verify_backup
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--boot-copies', required=True, type=Path)
parser.add_argument('--raw-backup', type=Path,
help='Use the verified raw slot A header, retaining the inventoried kernel')
args = parser.parse_args()
copies = args.boot_copies.resolve()
manifest = json.loads((copies/'manifest.json').read_text())
if manifest.get('device') != 'sargo':
raise ValueError('Expected verified sargo boot copies')
for item in manifest['files']:
path = copies/item['file']
if path.parent != copies or digest(path) != item['sha256'] or path.stat().st_size != item['bytes']:
raise ValueError('Boot copy changed: '+item['file'])
original = copies/('boot.img-'+manifest['runningKernel'])
folder = ROOT/'out/installed-sargo-diagnostic'
raw_manifest_hash = None
if args.raw_backup:
raw = args.raw_backup.resolve()
verify_backup(raw)
raw_manifest_hash = digest(raw/'manifest.json')
original = raw/'boot_a.img'
folder = ROOT/'out/sargo-raw-diagnostic'
folder.mkdir(parents=True, exist_ok=True)
source = ROOT/'boot/usb-init.c'
binary = folder/'init'
command = ['clang','--target=aarch64-linux-gnu',f'--ld-path={linker()}',
'-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie',
'-fno-unwind-tables','-fno-asynchronous-unwind-tables',
'-Wall','-Wextra','-Werror','-nostdlib','-static','-Wl,--build-id=none',
'-Wl,-e,_start',str(source),'-o',str(binary)]
subprocess.run(command, check=True)
data = binary.read_bytes()
if data[:4] != b'\x7fELF' or struct.unpack_from('<H',data,18)[0] != 183:
raise ValueError('Expected ARM64 init')
entries = [(p,stat.S_IFDIR|0o755,b'',0,0) for p in ('dev','proc','sys')]
entries += [('dev/console',stat.S_IFCHR|0o600,b'',5,1),
('init',stat.S_IFREG|0o755,data,0,0)]
ramdisk = folder/'initramfs.cpio.gz'
ramdisk.write_bytes(cpio(entries))
image = folder/'tundra-sargo-installed-kernel-NOT-FOR-FLASHING.img'
packager = checked_source('aosp-mkbootimg')
with tempfile.TemporaryDirectory() as temp:
unpack = Path(temp)/'original'
result = subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),
'--boot_img',str(original),'--out',str(unpack),'--format=mkbootimg'],
check=True,capture_output=True,text=True)
options = shlex.split(result.stdout)
fields = dict(zip(options[::2],options[1::2]))
if fields.get('--header_version') != '0':
raise ValueError('This recipe expects the inventoried v0 boot')
kernel = unpack/'kernel'
if digest(kernel) != digest(copies/('Image.lz4-dtb-'+manifest['runningKernel'])):
raise ValueError('Extracted kernel differs from inventoried kernel')
fields['--ramdisk'] = str(ramdisk)
# Preserve the board parameters; our PID 1 never mounts userdata or vendor.
fields['--cmdline'] += ' rdinit=/init tundra.usb=1'
build = [sys.executable,str(packager/'mkbootimg.py')]
for key,value in fields.items():
build += [key,value]
subprocess.run(build+['-o',str(image)],check=True)
if image.stat().st_size > 64*1024*1024:
raise ValueError('Image exceeds observed boot partition size')
verify = Path(temp)/'verify'
result = subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),
'--boot_img',str(image),'--out',str(verify),'--format=mkbootimg'],
check=True,capture_output=True,text=True)
rebuilt = shlex.split(result.stdout)
rebuilt = dict(zip(rebuilt[::2],rebuilt[1::2]))
for key,value in fields.items():
if key not in ('--kernel','--ramdisk') and rebuilt.get(key) != value:
raise ValueError('Header roundtrip mismatch: '+key)
if digest(verify/'kernel') != digest(kernel) or digest(verify/'ramdisk') != digest(ramdisk):
raise ValueError('Kernel/ramdisk roundtrip mismatch')
metadata = {k:v for k,v in fields.items() if k not in ('--kernel','--ramdisk')}
kernel_hash = digest(kernel)
save(folder/'build.json',{
'recordedAt':now(),'device':'sargo','kernel':manifest['runningKernel'],
'originalBootSha256':digest(original),'bootCopiesManifestSha256':digest(copies/'manifest.json'),
'rawBackupManifestSha256':raw_manifest_hash,
'rawBootSlot':'a' if args.raw_backup else None,
'kernelSha256':kernel_hash,'ramdiskSha256':digest(ramdisk),
'initSha256':digest(binary),'sourceSha256':digest(source),
'sharedInitSha256':digest(ROOT/'boot/init.c'),'compileCommand':command,
'header':metadata,'file':image.name,'imageSha256':digest(image),
'bytes':image.stat().st_size,'roundtripPassed':True,'hardwareTested':False,
'flashReady':False,'gaiaIncluded':False,'partitionsWritten':[],
'blockers':['Restoration not validated',
*([] if args.raw_backup else ['Raw partition backup not verified by this build']),
'Qualcomm UDC startup not tested with this ramdisk']})
print(image)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,239 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a private, temporary Sargo SSH ramdisk from verified installed helpers."""
import argparse
import base64
import importlib.util
import json
import os
from pathlib import Path
import shlex
import stat
import struct
import subprocess
import tempfile
import time
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization
from ramdisk_reference import read_newc, select_helpers
from tundra import ROOT, cpio, checked_source, digest, save, linker
def load_boot():
spec=importlib.util.spec_from_file_location('boot_test',ROOT/'tools/test-sargo-boot.py')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
return module
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('--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')
parser.add_argument('--runtime-config', action='store_true', help='Apply the current runtime recipe to the RAM overlay')
parser.add_argument('--network', action='store_true', help='Enable experimental WLAN and isolated Qualcomm NV copies; requires --runtime-config')
parser.add_argument('--hardware', action='store_true', help='Enable experimental modem/audio/camera HAL services; requires --network')
parser.add_argument('--local-debugger',action='store_true',help='Private RAM diagnosis only: Marionette on loopback 2858; never flash this image')
parser.add_argument('--diagnostics', action='store_true', help='Open apps and scroll Gaia during qualification; requires --runtime-config')
parser.add_argument('--state', type=Path, help='Prepared Tundra data-image manifest; enables persistent writes in that file')
parser.add_argument('--no-deadline', action='store_true', help='Keep the persistent development boot running; requires --state')
args=parser.parse_args()
if args.hold_hal and not args.native:parser.error('--hold-hal requires --native')
if args.system_image and not args.native:parser.error('--system-image requires --native')
if args.runtime_config and not args.native:parser.error('--runtime-config requires --native')
if args.hardware and not args.network:parser.error('--hardware requires --network')
if args.network and not args.runtime_config:parser.error('--network requires --runtime-config')
if args.diagnostics and not args.runtime_config:parser.error('--diagnostics requires --runtime-config')
if args.state and (not args.system_image or not args.runtime_config):
parser.error('--state requires --system-image and --runtime-config')
if args.no_deadline and not args.state:parser.error('--no-deadline requires --state')
state = None
if args.state:
from persistent_state import validate_manifest
state = validate_manifest(args.state)
system = None
if args.system_image:
system = json.loads(args.system_image.read_text())
image_path = args.system_image.resolve().parent/system['image']
if (system.get('device') != 'sargo' or not system.get('filesystemCheckPassed')
or digest(image_path) != system['imageSha256']):
raise ValueError('System image provenance mismatch')
if state and state['systemImageSha256'] != system['imageSha256']:
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 layout['device']!='/dev/mmcblk0p72' or layout['table']!='0 104448000 linear 259:40 329728' or int(layout['size'])!=53648801280:
raise ValueError('Recipe only supports the inventoried read-only Sargo layout')
source=Path(plan['backupDirectory'])/'boot_a.img'
packager=checked_source('aosp-mkbootimg')
unpack=out/'original'
result=subprocess.check_output(['python3',str(packager/'unpack_bootimg.py'),'--boot_img',str(source),'--out',str(unpack),'--format=mkbootimg'],text=True)
options=shlex.split(result);fields=dict(zip(options[::2],options[1::2]))
if fields['--header_version']!='0':raise ValueError('Expected header v0')
with tempfile.TemporaryDirectory() as temp:
helpers=select_helpers(read_newc((unpack/'ramdisk').read_bytes()),Path(temp),
extra=('sbin/blkid','sbin/e2fsck') if state else ())
files={name:(0o755 if name.startswith(('bin/','sbin/')) or '/ld-' in name else 0o644,content) for name,content in helpers.items()}
# ARM64 binaries in the Android ramdisk request the dynamic loader at
# /lib/ld-linux-aarch64.so.1. Some source ramdisks expose the same loader
# only below /lib/aarch64-linux-gnu; keep both paths in the trial image.
loader='lib/aarch64-linux-gnu/ld-linux-aarch64.so.1'
if loader in files:
files.setdefault('lib/ld-linux-aarch64.so.1', files[loader])
# Generate fresh trial credentials. Nothing from the installed ramdisk's
# /etc/dropbear or /root is copied. Keep this entire output private.
client=ec.generate_private_key(ec.SECP256R1())
(out/'client-key').write_bytes(client.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.OpenSSH,serialization.NoEncryption()))
public=client.public_key().public_bytes(serialization.Encoding.OpenSSH,serialization.PublicFormat.OpenSSH)
host=rsa.generate_private_key(public_exponent=65537,key_size=3072)
numbers=host.private_numbers()
def string(data):return struct.pack('>I',len(data))+data
def integer(n):
data=n.to_bytes((n.bit_length()+7)//8,'big')
return string((b'\0' if data[0]&128 else b'')+data)
# Dropbear src/rsa.c: type, e, n, d, p, q in SSH string/mpint format.
private=string(b'ssh-rsa')+b''.join(integer(n) for n in (numbers.public_numbers.e,numbers.public_numbers.n,numbers.d,numbers.p,numbers.q))
hostpublic=host.public_key().public_bytes(serialization.Encoding.OpenSSH,serialization.PublicFormat.OpenSSH).decode()
(out/'known_hosts').write_text('[10.15.19.82]:2222 '+hostpublic+'\n[127.0.0.1]:2222 '+hostpublic+'\n')
init=(ROOT/'boot/rescue-init.sh').read_bytes().replace(b'# No LVM activation',
('EXPECTED_STORAGE_HASH='+shlex.quote(layout['firstMiBSha256'])+'\n# No LVM activation').encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.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.
init=init.replace(b'--readonly --table', b'--table')
init=init.replace(b'-o ro,noload /dev/mapper/tundra-installed-ro', b'-o rw /dev/mapper/tundra-installed-ro')
init=init.replace(b'# Temporary RAM-only boot. Installed data is never mounted writable.', b'# Private persistent boot. Only the dedicated Tundra data file receives system writes.')
init=init.replace(b'# No LVM activation, fsck, journal replay, resizing, or writes to a block device.', b'# Use the inventoried filesystem without resizing or formatting it.')
sources.append(ROOT/'tools/persistent_state.py')
shared_ui = {}
if args.native:
binary=out/'watchdog'
subprocess.run(['clang','--target=aarch64-linux-gnu','--ld-path='+str(linker()),'-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie','-fno-unwind-tables','-fno-asynchronous-unwind-tables','-Wall','-Wextra','-Werror','-nostdlib','-static','-Wl,--build-id=none','-Wl,-e,_start',str(ROOT/'boot/watchdog.c'),'-o',str(binary)],check=True)
init=init.replace(b"(sleep 480; echo 'Tundra trial deadline reached'; reboot -f) &",b'/sbin/tundra-watchdog &')
if args.no_deadline:
init=init.replace(b'/sbin/tundra-watchdog &', b'# Persistent development boot: no timed reboot.')
init=init.replace(b'while :; do sleep 5; done',b'exec /bin/sh /native-root.sh')
import io,tarfile
with tempfile.TemporaryDirectory() as temporary:
configroot=Path(temporary)/'root'
(configroot/'opt/vulpes/trial').mkdir(parents=True)
(configroot/'opt/vulpes/trial/session.py').touch()
spec=importlib.util.spec_from_file_location('native',ROOT/'runtime/prepare-native.py')
native=importlib.util.module_from_spec(spec);spec.loader.exec_module(native);native.prepare(configroot, network=args.network, hardware=args.hardware, diagnostics=args.diagnostics)
display=configroot/'etc/systemd/system/tundra-display.service'
if display.exists():
display.write_text(display.read_text().replace('[Service]',
'[Service]\nEnvironment=TUNDRA_LOCAL_DEBUGGER='+('1' if args.local_debugger else '0')))
if state:
dropin=configroot/'etc/systemd/system/tundra-display.service.d'
dropin.mkdir(parents=True,exist_ok=True)
(dropin/'persistent.conf').write_text('[Service]\nEnvironment=TUNDRA_PERSISTENT=1\n')
users=configroot/'etc/systemd/system/systemd-sysusers.service.d'
users.mkdir(parents=True,exist_ok=True)
# Add newly required service accounts even when a previous
# boot marked /etc up to date. Existing identities are retained.
(users/'tundra.conf').write_text('[Unit]\nConditionNeedsUpdate=\nConditionCredential=\n')
buffer=io.BytesIO()
def owned(info):
info.uid=info.gid=0
info.uname=info.gname='root'
info.mtime=0
if info.isdir():info.mode=0o755
elif info.isfile() and info.name.endswith('.conf'):info.mode=0o644
return info
with tarfile.open(fileobj=buffer,mode='w') as archive:
# Most runtime policy lives under /etc, but a few reference
# applications select device backends from /usr/lib/droidian.
# Include every generated top-level directory so a native
# trial can override those selectors in its disposable layer.
for directory in ('etc', 'usr'):
config_dir = configroot/directory
if config_dir.exists():
archive.add(config_dir, arcname=directory, filter=owned)
files['native-config.tar']=(0o600,buffer.getvalue())
sources += list(sorted((ROOT/'runtime').glob('*.py')))
files['sbin/tundra-watchdog']=(0o755,binary.read_bytes())
native_init=(ROOT/'boot/native-root.sh').read_bytes().replace(b'# BUILD_TIME_FLOOR', ('BUILD_EPOCH='+str(built_at)).encode())
if args.runtime_config:
native_init=native_init.replace(b'# RUNTIME_CONFIG_OVERLAY', b'APPLY_NATIVE_CONFIG=1')
if system:
sha = system['imageSha256']
native_init = native_init.replace(b'# A kernel fallback',
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\n# A kernel fallback').encode())
# Bundle UI changes with the boot trial rather than retransferring
# the entire immutable system image for each CSS/JS adjustment.
buffer = io.BytesIO()
selected = [*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
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']
with tarfile.open(fileobj=buffer, mode='w') as archive:
for path in selected:
if not path.is_file(): continue
relative = str(path.relative_to(ROOT.parent))
target = 'trial/'+path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
archive.add(path, arcname='opt/vulpes/'+target, filter=owned)
shared_ui[relative] = digest(path)
files['shared-ui.tar'] = (0o600, buffer.getvalue())
if state:
config = ('STATE_IMAGE=/installed/tundra/data/'+state['uuid']+'.ext4\n'
'STATE_UUID='+state['uuid']+'\nSTATE_BYTES='+str(state['bytes'])+'\n')
native_init=native_init.replace(b'# A kernel fallback',config.encode()+b'# A kernel fallback')
if args.hold_hal:native_init=native_init.replace(b'--unit=tundra.target',b'--unit=basic.target')
files['native-root.sh']=(0o755,native_init)
sources += [ROOT/'boot/watchdog.c',ROOT/'boot/init.c',ROOT/'boot/native-root.sh']
files.update({'init':(0o755,init),'etc/tundra-hostkey':(0o600,private),
'root/.ssh/authorized_keys':(0o600,public+b'\n'),
'etc/passwd':(0o644,b'root:x:0:0:Tundra development:/root:/bin/sh\n'),
'etc/shadow':(0o600,b'root::20000:0:99999:7:::\n'),
'etc/group':(0o644,b'root:x:0:\n'),
'etc/shells':(0o644,b'/bin/sh\n'),
'etc/nsswitch.conf':(0o644,b'passwd: files\ngroup: files\nshadow: files\n')})
directories={'dev','proc','sys','run','installed'}
for name in files:
directories.update(str(p) for p in Path(name).parents if str(p)!='.')
entries=[(n,stat.S_IFDIR|(0o700 if n.startswith('root') else 0o755),b'',0,0) for n in sorted(directories)]
entries += [(n,stat.S_IFREG|mode,data,0,0) for n,(mode,data) in sorted(files.items())]
entries += [('bin/sh',stat.S_IFLNK|0o777,b'busybox',0,0),('dev/console',stat.S_IFCHR|0o600,b'',5,1)]
ramdisk=out/'initramfs.cpio.gz';ramdisk.write_bytes(cpio(entries))
kernel_hash=digest(unpack/'kernel')
fields['--ramdisk']=str(ramdisk);fields['--cmdline']+=' rdinit=/init tundra.rescue=1 panic=10'
image=out/('tundra-sargo-PRIVATE-PERSISTENT.img' if state else 'tundra-sargo-PRIVATE-RAM-TRIAL.img')
command=['python3',str(packager/'mkbootimg.py')]
for k,v in fields.items():command.extend([k,v])
subprocess.run(command+['-o',str(image)],check=True)
if image.stat().st_size>67108864:raise ValueError('Boot image too large')
verify=out/'verify'
subprocess.run(['python3',str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
if digest(verify/'kernel')!=digest(unpack/'kernel') or digest(verify/'ramdisk')!=digest(ramdisk):raise ValueError('Roundtrip failed')
import shutil
# The source ramdisk contains installed private keys: retain no extracted copy.
shutil.rmtree(unpack);shutil.rmtree(verify)
# Keep the inputs beside the immutable image. Later edits to the checkout
# must not invalidate an otherwise intact, qualified recovery trial.
snapshot=out/'sources'
for path in sources + [ROOT.parent/name for name in shared_ui]:
target=snapshot/path.relative_to(ROOT.parent)
target.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(path,target)
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,
'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()},
'containsPrivateTrialHostKey':True,'installedKeysCopied':False})
print(image)
if __name__=='__main__':main()

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Package the ARM64 runtime reference. This image has no qualified boot sequence."""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def pack(tree, image):
if not os.environ.get('FAKEROOTKEY'):
raise RuntimeError('Ownership normalization must run under fakeroot')
for base, dirs, files in os.walk(tree):
os.lchown(base, 0, 0)
for name in dirs+files:
os.lchown(Path(base)/name, 0, 0)
os.chown(tree/'home/vulpes', 1000, 1000)
# Explicit feature list avoids enabling newer ext4 features on the 4.9 kernel.
features = 'none,has_journal,ext_attr,dir_index,filetype,extent,sparse_super,large_file,uninit_bg,dir_nlink,extra_isize'
subprocess.run(['mke2fs', '-q', '-t', 'ext4', '-F', '-b', '4096', '-I', '256',
'-O', features, '-L', 'TUNDRA_REF', '-d', str(tree), str(image)], check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference')
parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product')
parser.add_argument('--output', type=Path, default=ROOT/'out/sargo-runtime-image')
parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.pack:
pack(*args.pack)
return
if os.geteuid() == 0:
parser.error('Run as a regular user; fakeroot supplies image ownership')
output = args.output.resolve()
if output.exists():
parser.error('Output exists; select a new directory to preserve its provenance')
spec = importlib.util.spec_from_file_location('stage_runtime', ROOT/'tools/stage-runtime.py')
stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
tree = output/'root'
staged = stage.stage(args.source, tree)
product = args.product.resolve()
selected = ('assets', 'host', 'services', 'adapters', 'overrides', 'gaia', 'tools',
'tests', 'engines', 'trial', 'engine-lock.json', 'release.json')
for name in selected:
source, target = product/name, tree/'opt/vulpes'/name
if source.is_dir():
shutil.copytree(source, target, symlinks=True,
ignore=shutil.ignore_patterns('__pycache__'), copy_function=os.link)
else:
os.link(source, target)
image = output/'tundra-sargo-runtime-NOT-BOOTABLE.ext4'
with image.open('xb') as stream:
stream.truncate(2*1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(Path(__file__).resolve()),
'--pack', str(tree), str(image)], check=True)
check = subprocess.run(['e2fsck', '-f', '-n', str(image)], capture_output=True, text=True)
(output/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:
raise RuntimeError('Generated filesystem failed e2fsck')
metadata = {'scope': 'Runtime and shared Gaia/Gecko files; no init/HAL configuration, NOT BOOTABLE',
'flashReady': False, 'standaloneBootTested': False, 'staging': staged,
'image': image.name, 'imageSha256': digest(image), 'bytes': image.stat().st_size,
'engine': json.loads((product/'engine-lock.json').read_text()),
'filesystemCheckPassed': True,
'scripts': {p.name: digest(p) for p in (Path(__file__).resolve(), ROOT/'tools/stage-runtime.py')}}
(output/'manifest.json').write_text(json.dumps(metadata, indent=2)+'\n')
print(image, metadata['imageSha256'], flush=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Create a new private data image; never formats a phone partition."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import uuid
from persistent_state import FEATURES, digest
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--system-image', type=Path, required=True)
parser.add_argument('--size-mib', type=int, default=2048)
args = parser.parse_args()
if args.size_mib < 512 or args.size_mib > 8192:
parser.error('Choose 512–8192 MiB')
system = json.loads(args.system_image.read_text())
base = args.system_image.parent/system['image']
if system.get('device') != 'sargo' or not system.get('filesystemCheckPassed') or digest(base) != system['imageSha256']:
raise ValueError('System image provenance mismatch')
os.umask(0o077)
out = args.output.resolve()
out.mkdir(parents=True, exist_ok=False)
seed = out/'seed'
seed.mkdir()
(seed/'system.sha256').write_text(system['imageSha256']+'\n')
for directory in ('upper','work'):
(seed/directory).mkdir(mode=0o755 if directory == 'upper' else 0o700)
(seed/'upper').chmod(0o755)
identifier = str(uuid.uuid4())
image = out/(identifier+'.ext4')
with image.open('xb') as stream:
stream.truncate(args.size_mib*1024**2)
subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-I','256','-O',FEATURES,
'-U',identifier,'-L','TUNDRA_DATA','-d',str(seed),str(image)],check=True)
# Root owns the overlay metadata, regardless of the build account's UID.
for name in ('/system.sha256','/upper','/work'):
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)
check = subprocess.run(['e2fsck','-f','-n',str(image)],text=True,capture_output=True)
(out/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:raise RuntimeError('Data image failed filesystem verification')
manifest = {'schema':1,'device':'sargo','uuid':identifier,'image':image.name,
'imageSha256':digest(image),'bytes':image.stat().st_size,
'systemImageSha256':system['imageSha256'],'filesystemCheckPassed':True,
'scope':'Private persistent overlay; containing filesystem retained; no partition formatting'}
(out/'state.json').write_text(json.dumps(manifest,indent=2)+'\n')
print(out/'state.json')
if __name__ == '__main__':main()

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Assemble a private Sargo system image, including its Android HAL reference."""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT.parent
ANDROID_SHA = '132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886'
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
def pack(root, image):
if not os.environ.get('FAKEROOTKEY'):
raise RuntimeError('Image ownership must be prepared under fakeroot')
for directory, dirs, files in os.walk(root):
for path in [Path(directory), *[Path(directory)/n for n in dirs+files]]:
os.lchown(path, 0, 0)
# The trial session refreshes its local Gecko host files before starting.
for base in (root/'opt/vulpes', root/'home/droidian'):
for directory, dirs, files in os.walk(base):
for path in [Path(directory), *[Path(directory)/n for n in dirs+files]]:
os.lchown(path, 32011, 32011)
features = 'none,has_journal,ext_attr,dir_index,filetype,extent,sparse_super,large_file,uninit_bg,dir_nlink,extra_isize'
subprocess.run(['mke2fs', '-q', '-t', 'ext4', '-F', '-b', '4096', '-I', '256',
'-O', features, '-L', 'TUNDRA_SYSTEM', '-d', str(root), str(image)], check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--reference', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference-20260925')
parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product')
parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.pack:
pack(*args.pack)
return
if os.geteuid() == 0:
parser.error('Build as a regular user')
android = ROOT/'downloads/sargo-installed/android-rootfs.img'
if digest(android) != ANDROID_SHA:
raise ValueError('Android HAL reference changed')
lock = json.loads((args.product/'engine-lock.json').read_text())
current = json.loads((PROJECT/'engine-lock.json').read_text())
if lock.get('architecture') != 'aarch64' or lock['version'] != current['version']:
raise ValueError('Prepare the matching ARM64 engine first')
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=False)
root = output/'root'
staging = module('stage', ROOT/'tools/stage-runtime.py').stage(args.reference, root)
product = root/'opt/vulpes'
# Read UI/services from the common checkout, and only the engine/EGL adapter
# from the checked ARM64 product. Never copy profiles or phone configuration.
for name in ('assets', 'host', 'services', 'adapters', 'overrides', 'gaia/shared', 'gaia/apps/system'):
shutil.copytree(PROJECT/name, product/name, symlinks=True,
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'):
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')
shutil.copy2(args.product/'engine-lock.json', product/'engine-lock.json')
shutil.copytree(args.product/'engines', product/'engines', symlinks=True)
shutil.copytree(ROOT/'droidian', product/'trial', ignore=shutil.ignore_patterns('__pycache__'))
for name in ('libEGL_vulpes_hybris.so', 'manifest.json'):
shutil.copy2(args.product/'trial/graphics'/name, product/'trial/graphics'/name)
for name in ('home/droidian', 'etc/dbus-1', 'reference'):
(root/name).mkdir(parents=True, exist_ok=True)
shutil.copy2(android, root/'reference/android-rootfs.img')
module('native', ROOT/'runtime/prepare-native.py').prepare(root)
shutil.copy2(ROOT/'droidian/initialize-runtime.sh', root/'etc/tundra/initialize-runtime.sh')
# Caches are architecture dependent; generate them with the target binaries.
(root/'etc/systemd/system/tundra-initialize.service').write_text('''[Unit]
Description=Initialize Tundra runtime caches
Before=tundra-hal.service tundra-display.service
[Service]
Type=oneshot
ExecStart=/bin/sh /etc/tundra/initialize-runtime.sh
RemainAfterExit=yes
''')
for service in ('tundra-hal', 'tundra-display'):
folder = root/f'etc/systemd/system/{service}.service.d'
folder.mkdir(exist_ok=True)
(folder/'initialize.conf').write_text('[Unit]\nRequires=tundra-initialize.service\nAfter=tundra-initialize.service\n')
image = output/'tundra-sargo-system.ext4'
with image.open('xb') as stream:
stream.truncate(3*1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(Path(__file__).resolve()),
'--output', str(output), '--pack', str(root), str(image)], check=True)
check = subprocess.run(['e2fsck', '-f', '-n', str(image)], capture_output=True, text=True)
(output/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:
raise RuntimeError('System image failed filesystem verification')
manifest = {'schema': 1, 'device': 'sargo', 'image': image.name,
'imageSha256': digest(image), 'bytes': image.stat().st_size,
'androidSha256': ANDROID_SHA, 'engine': lock,
'release': json.loads((product/'release.json').read_text()),
'staging': staging, 'filesystemCheckPassed': True,
'flashReady': False, 'hardwareBootTested': False,
'scope': 'Self-contained userspace; vendor/firmware partitions still required; RAM boot candidate',
'sourceHashes': {str(p.relative_to(PROJECT)): digest(p) for p in
[Path(__file__).resolve(), ROOT/'tools/stage-runtime.py',
*sorted((ROOT/'runtime').glob('*.py'))]}}
(output/'system.json').write_text(json.dumps(manifest, indent=2)+'\n')
print(image, manifest['imageSha256'], flush=True)
if __name__ == '__main__':
main()

44
tundra/tools/build-usb.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Compile a read-only FunctionFS diagnostic. Does not access a phone."""
import json
from pathlib import Path
import stat
import struct
import subprocess
import sys
from tundra import ROOT, cpio, linker, digest, now, save, kernel_files, checked_source
def main():
folder=ROOT/'out/usb-diagnostic';folder.mkdir(parents=True,exist_ok=True)
common=['-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie',
'-fno-unwind-tables','-fno-asynchronous-unwind-tables','-Wall','-Wextra','-Werror','-nostdlib','-static']
builds={}
for arch in ('x86_64','aarch64'):
out=folder/arch;out.mkdir(exist_ok=True)
cmd=(['gcc','-no-pie','-mgeneral-regs-only'] if arch=='x86_64' else ['clang','--target=aarch64-linux-gnu',f'--ld-path={linker()}'])
cmd += common+['-Wl,--build-id=none','-Wl,-e,_start',str(ROOT/'boot/usb-init.c'),'-o',str(out/'init')]
subprocess.run(cmd,check=True)
data=(out/'init').read_bytes()
assert struct.unpack_from('<H',data,18)[0]==(62 if arch=='x86_64' else 183)
entries=[(p,stat.S_IFDIR|0o755,b'',0,0) for p in ('dev','proc','sys')]
entries += [('dev/console',stat.S_IFCHR|0o600,b'',5,1),('init',stat.S_IFREG|0o755,data,0,0)]
(out/'initramfs.cpio.gz').write_bytes(cpio(entries))
builds[arch]={'initSha256':digest(out/'init'),'ramdiskSha256':digest(out/'initramfs.cpio.gz'),'command':cmd}
kernel=kernel_files();board=json.loads((ROOT/'devices/sargo/device.json').read_text())['diagnosticBoot']
image=folder/'tundra-sargo-usb-NOT-FOR-FLASHING.img'
cmd=[sys.executable,str(checked_source('aosp-mkbootimg')/'mkbootimg.py'),
'--kernel',str(kernel/'Image.lz4-dtb'),'--ramdisk',str(folder/'aarch64/initramfs.cpio.gz'),
'--header_version',str(board['headerVersion']),'--pagesize',str(board['pageSize']),
'--base',board['base'],'--kernel_offset',board['kernelOffset'],'--ramdisk_offset',board['ramdiskOffset'],
'--tags_offset',board['tagsOffset'],'--cmdline',board['cmdline']+' tundra.usb=1','-o',str(image)]
subprocess.run(cmd,check=True)
if image.stat().st_size>board['partitionSizeBytes']:raise ValueError('Image too large')
save(folder/'build.json',{'recordedAt':now(),'sourceSha256':digest(ROOT/'boot/usb-init.c'),
'architectures':builds,'sharedInitSha256':digest(ROOT/'boot/init.c'),'file':image.name,'imageSha256':digest(image),'bytes':image.stat().st_size,
'hardwareTested':False,'flashReady':False,'protocol':'read-only FunctionFS, not ADB',
'blockers':['Real firmware/slot/recovery inventory missing','Sargo UDC initialization not tested','No Gecko or Gaia in this diagnostic']})
print(image)
if __name__=='__main__':main()

254
tundra/tools/build-vm.py Normal file
View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Assemble an isolated x86 graphical prototype from pinned Vulpes and local Linux tools.
This is a development image, not a redistributable distro or a Pixel ROM.
No root, package installation, personal profile or host filesystem mount is needed.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import time
import urllib.request
from tundra import cpio
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT.parent
OUT = ROOT / 'out/graphical-vm'
FS = OUT / 'root'
WESTON = ROOT / 'out/graphical'
seen = set()
def sha(path):
with open(path, 'rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def logical(path):
s = str(path)
for prefix in ('/lib64/', '/lib/'):
if s.startswith(prefix):
return '/usr/lib/' + s[len(prefix):]
return s
def copy(path, dest=None, deps=True):
path = Path(path)
dest = logical(dest or path)
target = FS / dest.lstrip('/')
if dest in seen:
return
if not path.is_file():
raise FileNotFoundError(path)
seen.add(dest)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
shutil.copy2(path, target, follow_symlinks=True)
target.chmod(target.stat().st_mode & 0o777)
if deps and path.open('rb').read(4) == b'\x7fELF':
runtime = PROJECT / json.loads((PROJECT / 'engine-lock.json').read_text())['runtime']
env = dict(os.environ, LD_LIBRARY_PATH=f'{WESTON}/usr/lib/weston:{WESTON}/usr/lib:{runtime}')
result = subprocess.run(['ldd', str(path)], env=env, capture_output=True, text=True)
if 'not found' in result.stdout:
raise RuntimeError(f'Missing dependency for {path}: {result.stdout}')
for library in re.findall(r'(?:=>\s+|^\s*)(/\S+)', result.stdout, re.M):
destination = library
if library.startswith(str(WESTON)):
destination = library[len(str(WESTON)):]
elif library.startswith(str(PROJECT / 'engines')):
destination = '/opt/vulpes/' + str(Path(library).relative_to(PROJECT))
copy(library, destination)
def tree(source, destination, exclude=()):
source = Path(source)
for base, dirs, files in os.walk(source, followlinks=True):
dirs[:] = [d for d in dirs if d not in exclude]
for name in files:
p = Path(base) / name
if name.endswith(('.pyc', '.pyo')) or name in exclude:
continue
if p.is_file():
copy(p, str(Path(destination) / p.relative_to(source)))
def write(path, data, mode=0o644):
p = FS / path.lstrip('/')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(data)
p.chmod(mode)
def initramfs(kernel):
minimal = OUT / 'initroot'
minimal.mkdir(exist_ok=True)
paths = set()
def include(path):
p = Path(logical(path))
if str(p) in paths:
return
paths.add(str(p))
output = subprocess.run(['ldd', str(p)], capture_output=True, text=True).stdout
for lib in re.findall(r'(?:=>\s+|^\s*)(/\S+)', output, re.M):
include(lib)
for binary in ('bash', 'mount', 'modprobe', 'switch_root', 'mkdir', 'sleep'):
include('/usr/bin/' + binary)
modules = set()
for module in ('virtio_pci', 'virtio_blk', 'ext4', 'virtio_gpu', 'virtio_input', 'evdev', 'dummy_hcd', 'libcomposite', 'usb_f_fs', 'usbcore'):
output = subprocess.check_output(['modprobe', '--show-depends', module], text=True)
for line in output.splitlines():
if line.startswith('insmod '):
modules.add(logical(line.split()[1]))
paths.update(modules)
moddir = Path('/usr/lib/modules') / kernel
paths.update(str(p) for p in moddir.glob('modules.*') if p.is_file())
entries = []
dirs = {'dev', 'proc', 'sys', 'newroot', 'run', 'usr', 'usr/bin', 'usr/lib'}
for path in sorted(paths):
p = Path(path)
name = path.lstrip('/')
dirs.update(str(d) for d in Path(name).parents if str(d) != '.')
entries.append((name, stat.S_IFREG | (p.stat().st_mode & 0o777), p.read_bytes(), 0, 0))
copy(p, deps=False)
script = '''#!/usr/bin/bash
set -eux
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
mount -t tmpfs tmpfs /run
for m in virtio_pci virtio_blk ext4 virtio_gpu virtio_input evdev; do modprobe "$m"; done
for ((i=0;i<100;i++)); do [[ -b /dev/vda ]] && break; sleep .1; done
mount /dev/vda /newroot
mkdir -p /newroot/{dev,proc,sys}
mount --move /dev /newroot/dev
mount --move /proc /newroot/proc
mount --move /sys /newroot/sys
mount --move /run /newroot/run
exec switch_root /newroot /usr/bin/python3 -u /opt/tundra/init.py
'''
entries = [(d, stat.S_IFDIR | 0o755, b'', 0, 0) for d in sorted(dirs, key=lambda s: (s.count('/'), s))] + entries
entries += [('lib', stat.S_IFLNK | 0o777, b'usr/lib', 0, 0),
('lib64', stat.S_IFLNK | 0o777, b'usr/lib', 0, 0),
('bin', stat.S_IFLNK | 0o777, b'usr/bin', 0, 0),
('init', stat.S_IFREG | 0o755, script.encode(), 0, 0),
('dev/console', stat.S_IFCHR | 0o600, b'', 5, 1)]
(OUT / 'initramfs.cpio.gz').write_bytes(cpio(entries))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--refresh', action='store_true', help='Refresh shared Vulpes/guest files in an existing staged root')
args = parser.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
FS.mkdir(exist_ok=True)
if args.refresh and not (OUT/'build.json').exists():
raise SystemExit('Run a full build before --refresh')
if not (ROOT/'out/usb-diagnostic/x86_64/init').exists():
subprocess.run([sys.executable,str(ROOT/'tools/build-usb.py')],check=True)
if not args.refresh:
lock = json.loads((ROOT / 'manifests/weston.lock.json').read_text())
archive = ROOT / 'downloads/weston' / lock['url'].rsplit('/', 1)[1]
if not archive.exists():
archive.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(lock['url'], timeout=60) as response, archive.with_suffix('.part').open('wb') as output:
shutil.copyfileobj(response, output)
archive.with_suffix('.part').replace(archive)
if sha(archive) != lock['sha256']:
raise RuntimeError('Weston checksum mismatch')
WESTON.mkdir(parents=True, exist_ok=True)
subprocess.run(['tar','--no-same-owner','-xf',str(archive),'-C',str(WESTON)],check=True)
for link, dest in [('bin','usr/bin'), ('sbin','usr/bin'), ('lib','usr/lib'), ('lib64','usr/lib'), ('usr/lib64','lib')]:
p = FS / link
p.parent.mkdir(parents=True, exist_ok=True)
if not p.exists() and not p.is_symlink():
p.symlink_to(dest)
tree(WESTON / 'usr', '/usr', ('vnc-backend.so','rdp-backend.so','pipewire-backend.so','xwayland.so','vulkan-renderer.so'))
for name in ('bash','python3','mount','umount','modprobe','mkdir','sleep','ip','udevadm','dbus-daemon','dbus-run-session','seatd','setpriv','sync','fc-cache','bwrap','unshare'):
copy('/usr/bin/' + name)
copy('/usr/lib/systemd/systemd-udevd')
copy('/usr/bin/sh')
for program in ('at-spi-bus-launcher','at-spi2-registryd','xdg-desktop-portal','xdg-desktop-portal-gtk','xdg-permission-store','xdg-document-portal'):
copy('/usr/lib/'+program)
copy('/usr/lib/libkmod.so.2')
copy('/usr/lib/udev/libinput-device-group')
rules = FS / 'usr/lib/udev/rules.d'
if rules.exists(): shutil.rmtree(rules)
for name in ('60-input-id.rules','60-drm.rules','70-mouse.rules','71-seat.rules','73-seat-late.rules','80-libinput-device-groups.rules','90-libinput-fuzz-override.rules'):
p = Path('/usr/lib/udev/rules.d') / name
if p.exists(): copy(p)
write('/usr/lib/udev/rules.d/50-tundra.rules', 'SUBSYSTEM=="input", GROUP="video", MODE="0660"\nSUBSYSTEM=="drm", GROUP="video", MODE="0660"\nSUBSYSTEM=="tty", KERNEL=="tty[0-9]*", GROUP="video", MODE="0660"\n')
python = Path(sys.base_prefix) / 'lib' / f'python{sys.version_info.major}.{sys.version_info.minor}'
tree(python, str(python), ('site-packages','__pycache__','test','tests','idlelib','tkinter','ensurepip'))
for dirname in ('/usr/share/xdg-desktop-portal','/usr/share/mime','/usr/lib/glycin-loaders','/usr/share/glycin-loaders','/usr/lib/locale/C.utf8','/usr/share/libinput','/usr/share/X11/xkb','/usr/share/glvnd/egl_vendor.d','/usr/share/glib-2.0/schemas','/usr/lib/gdk-pixbuf-2.0','/usr/share/dbus-1','/etc/fonts'):
tree(dirname, dirname)
for name in ('libEGL.so.1','libEGL_mesa.so.0','libGL.so.1','libGLX.so.0','libGLX_mesa.so.0','libGLESv2.so.2','libgbm.so.1','libgtk-3.so.0','libgdk-3.so.0','libnss_dns.so.2','libnss_files.so.2'):
p = Path('/usr/lib') / name
if p.exists(): copy(p)
for name in ('swrast_dri.so','virtio_gpu_dri.so'):
copy('/usr/lib/dri/' + name)
for gallium in Path('/usr/lib').glob('libgallium-*.so'):
copy(gallium)
for font in Path('/usr/share/fonts/TTF').glob('DejaVuSans*.ttf'):
copy(font, deps=False)
for font in (PROJECT / 'gaia/shared').rglob('*.ttf'):
copy(font, '/usr/share/fonts/vulpes/' + font.name, deps=False)
kernel = os.uname().release
shutil.copy2('/boot/vmlinuz-linux', OUT / 'vmlinuz')
initramfs(kernel)
write('/etc/passwd', 'root:x:0:0:root:/root:/usr/bin/bash\nvulpes:x:1000:1000:Vulpes:/home/vulpes:/usr/bin/bash\n')
write('/etc/group', 'root:x:0:\nvideo:x:44:vulpes\nrender:x:992:vulpes\nvulpes:x:1000:\n')
write('/etc/nsswitch.conf', 'passwd: files\ngroup: files\nhosts: files dns\n')
write('/etc/hosts', '127.0.0.1 localhost\n::1 localhost\n')
write('/etc/os-release', 'NAME="Tundra development VM"\nID=tundra\nPRETTY_NAME="Tundra x86_64 prototype"\n')
write('/etc/machine-id', 'a30d62a5b7954ddda12e13ab83aaff52\n')
write('/etc/ld.so.conf', '/usr/lib\n/usr/lib/weston\n')
# Refresh the generated payload as a unit so removed source files stay removed.
for name in ('assets','host','services','adapters','overrides','gaia','tools','tests'):
path=FS/'opt/vulpes'/name
if path.exists(): shutil.rmtree(path)
if (FS/'opt/tundra').exists(): shutil.rmtree(FS/'opt/tundra')
# Only distributable product inputs; never the user's profiles, keys or history.
for name in ('assets','host','services','adapters','overrides'):
tree(PROJECT / name, '/opt/vulpes/' + name, ('__pycache__',))
tree(PROJECT / 'gaia/shared', '/opt/vulpes/gaia/shared', ('test','tests'))
tree(PROJECT / 'gaia/apps/system', '/opt/vulpes/gaia/apps/system', ('test','tests'))
for name in ('engine-lock.json','release.json'):
copy(PROJECT / name, '/opt/vulpes/' + name)
for name in ('serve-gaia.py','project.py','run-host.py','marionette_client.py'):
copy(PROJECT / 'tools' / name, '/opt/vulpes/tools/' + name)
copy(PROJECT / 'tests/ProbeChild.sys.mjs', '/opt/vulpes/tests/ProbeChild.sys.mjs')
engine = json.loads((PROJECT / 'engine-lock.json').read_text())
if not args.refresh:
tree(PROJECT / engine['runtime'], '/opt/vulpes/' + engine['runtime'], ('vulpes','updated','updates'))
tree(ROOT / 'vm', '/opt/tundra', ('__pycache__',))
copy(ROOT/'out/usb-diagnostic/x86_64/init','/opt/tundra/usb-init')
copy(ROOT/'tools/usb_protocol.py','/opt/tundra/usb_protocol.py')
for directory in ('dev','proc','sys','run','tmp','var/log','var/cache/fontconfig','home/vulpes','root'):
(FS / directory).mkdir(parents=True, exist_ok=True)
(FS / 'tmp').chmod(0o1777)
subprocess.run(['/usr/bin/ldconfig','-r',str(FS)], check=True)
print('Writing ext4 image…', flush=True)
image = OUT / 'root.ext4'
with image.open('wb') as f:
f.truncate(4 * 1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(ROOT/'tools/pack-rootfs.py'), str(FS), str(image)], check=True)
files = {}
for p in sorted(FS.rglob('*')):
if p.is_file() and not p.is_symlink():
files[str(p.relative_to(FS))] = sha(p)
report = {'scope':'x86_64 Linux/Wayland development VM; no Android HAL or Pixel validation',
'gecko':engine['version'], 'kernel':os.uname().release, 'created':time.time(),
'rootImageSha256':sha(image),'network':'loopback only; no virtual NIC', 'renderer':'Weston Pixman',
'files':files, 'initramfsSha256':sha(OUT/'initramfs.cpio.gz'), 'kernelSha256':sha(OUT/'vmlinuz')}
(OUT / 'build.json').write_text(json.dumps(report, indent=2) + '\n')
print(f'Ready: {image} ({len(files)} files recorded)', flush=True)
if __name__ == '__main__': main()

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read a Linux phone inventory through an already authenticated SSH master."""
import argparse
import ipaddress
import os
from pathlib import Path
import re
import shlex
import subprocess
from tundra import ROOT, now, save
READS = {
'identity': ['id'],
'os-release': ['cat', '/etc/os-release'],
'kernel': ['uname', '-a'],
'cmdline': ['cat', '/proc/cmdline'],
'partitions': ['lsblk', '-b', '-J', '-o', 'NAME,TYPE,SIZE,FSTYPE,PARTLABEL,MOUNTPOINTS'],
'partition-links': ['ls', '-l', '/dev/disk/by-partlabel', '/dev/block/by-name'],
'mounts': ['cat', '/proc/mounts'],
'fstab': ['cat', '/etc/fstab'],
'deviceinfo': ['ls', '-la', '/etc/deviceinfo'],
'boot-files': ['ls', '-l', '/boot'],
'devices': ['ls', '-l', '/dev/dri', '/dev/input', '/dev/video0', '/dev/binder'],
'services': ['systemctl', 'list-units', '--type=service', '--state=running', '--no-pager', '--plain'],
'packages': ['dpkg-query', '-W', '-f', '${binary:Package}\t${Version}\n'],
'android-properties': ['getprop'],
}
def identify(model, compatible, android_device=''):
model = model.replace('\0', '').strip()
tokens = compatible.strip().split('\0')
android_device = android_device.strip()
if model == 'Google Pixel 3a XL' or 'google,bonito' in tokens or android_device == 'bonito':
return 'wrong-device-pixel-3a-xl'
if model == 'Google Pixel 3a' or 'google,sargo' in tokens or android_device == 'sargo':
return 'sargo'
return 'unverified'
def ssh_base(socket, host, user):
# ProxyCommand=false prevents a new connection if the master disappears.
return ['ssh', '-F', '/dev/null', '-S', str(socket),
'-o', 'BatchMode=yes', '-o', 'ProxyCommand=false',
'-o', 'ForwardAgent=no', '-o', 'ClearAllForwardings=yes',
f'{user}@{host}']
def capture(base, command):
try:
result = subprocess.run(base + [shlex.join(command)], capture_output=True,
text=True, errors='replace', timeout=20)
output = result.stdout
if command == READS['packages']:
output = '\n'.join(line for line in output.splitlines() if re.search(
r'android|droidian|hybris|halium|linux-|phosh|phoc|wayland|mesa|firmware|modem|pulse|pipewire',
line, re.I))
return {'command': command, 'returncode': result.returncode,
'stdout': output[:65536], 'stderr': result.stderr[:4096],
'truncated': len(output) > 65536}
except subprocess.TimeoutExpired:
return {'command': command, 'returncode': None, 'error': 'timeout'}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--control-socket', type=Path, required=True)
parser.add_argument('--host', default='10.15.19.82')
parser.add_argument('--user', default='droidian')
args = parser.parse_args()
ipaddress.ip_address(args.host)
if not re.fullmatch(r'[a-z_][a-z0-9_-]*', args.user):
parser.error('Invalid SSH user')
socket = args.control_socket.resolve()
if not socket.is_socket():
parser.error('Open the authenticated SSH master in your terminal first')
os.umask(0o077)
base = ssh_base(socket, args.host, args.user)
subprocess.run(base[:-1] + ['-O', 'check', base[-1]], check=True)
records = {name: capture(base, ['cat', path]) for name, path in (
('model', '/proc/device-tree/model'),
('compatible', '/proc/device-tree/compatible'))}
records['android-device'] = capture(base, ['getprop', 'ro.product.device'])
value = lambda name: records[name].get('stdout', '') if records[name].get('returncode') == 0 else ''
identity = identify(value('model'), value('compatible'), value('android-device'))
if identity == 'sargo':
for name, command in READS.items():
records[name] = capture(base, command)
path = ROOT/'logs/device'/('ssh-'+now().replace(':', '-')+'.json')
save(path, {'recordedAt': now(), 'transport': 'ssh-existing-master',
'identity': identity, 'readOnly': True,
'flashAuthorizedByThisReport': False, 'records': records})
print(f'Inventory: {path}\nIdentity: {identity}')
return 0 if identity == 'sargo' else 2
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read-only sargo inventory. Never reboot, root, unlock, mount, push or flash."""
import argparse
import os
import re
import subprocess
import sys
from tundra import ROOT, now, save
PROPERTIES = (
"ro.product.device", "ro.product.vendor.device", "ro.product.name",
"ro.build.fingerprint", "ro.vendor.build.fingerprint", "ro.build.version.release",
"ro.build.version.security_patch", "ro.boot.slot_suffix", "ro.boot.bootloader",
"ro.boot.verifiedbootstate", "ro.boot.flash.locked", "ro.boot.hardware",
)
READS = {
"os-release": ["cat", "/etc/os-release"],
"kernel": ["uname", "-a"],
"device-model": ["cat", "/proc/device-tree/model"],
"device-compatible": ["cat", "/proc/device-tree/compatible"],
"deviceinfo": ["cat", "/etc/deviceinfo"],
"system-image": ["cat", "/etc/system-image/channel.ini"],
"partitions": ["cat", "/proc/partitions"],
"partition-links": ["ls", "-l", "/dev/disk/by-partlabel"],
"android-partition-links": ["ls", "-l", "/dev/block/by-name"],
"mounts": ["cat", "/proc/mounts"],
}
def capture(command):
try:
p = subprocess.run(command, capture_output=True, text=True, timeout=15)
return {"returncode": p.returncode, "stdout": p.stdout[:65536], "stderr": p.stderr[:4096]}
except subprocess.TimeoutExpired:
return {"returncode": None, "error": "timeout"}
def identify(records):
values = []
for prop in ("ro.product.device", "ro.product.vendor.device"):
result = records.get(prop, {})
if result.get("returncode") == 0:
values.append(result.get("stdout", "").strip())
if "bonito" in values:
return "wrong-device-pixel-3a-xl"
if "sargo" in values:
return "sargo"
# Ubuntu Touch may have no getprop executable. Do not infer from SDM670 alone.
result = records.get("device-model", {})
model = result.get("stdout", "").replace("\0", "").strip() if result.get("returncode") == 0 else ""
if model == "Google Pixel 3a":
return "sargo-model-only"
return "unverified"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--serial", required=True, help="Explicit adb/fastboot transport serial")
parser.add_argument("--transport", choices=("adb", "fastboot"), default="adb")
args = parser.parse_args()
if not re.fullmatch(r"[A-Za-z0-9_.:-]+", args.serial) or args.serial.startswith("-"):
parser.error("Invalid device serial")
os.umask(0o077)
base = [args.transport, "-s", args.serial]
records = {}
if args.transport == "adb":
state = capture(base + ["get-state"])
if state.get("returncode") != 0 or state.get("stdout", "").strip() != "device":
sys.exit("Selected device is not available/authorized; no device changes made")
for prop in PROPERTIES:
records[prop] = capture(base + ["shell", "getprop", prop])
for name, command in READS.items():
records[name] = capture(base + ["shell", *command])
identity = identify(records)
else:
for name in ("product", "current-slot", "unlocked", "secure", "version-bootloader", "version-baseband"):
records[name] = capture(base + ["getvar", name])
product = records["product"]
text = product.get("stdout", "") + product.get("stderr", "")
identity = "sargo" if product.get("returncode") == 0 and re.search(r"\bproduct:\s*sargo\b", text) else "unverified"
path = ROOT / "logs/device" / (now().replace(":", "-") + ".json")
save(path, {"recordedAt": now(), "transport": args.transport, "identity": identity,
"readOnly": True, "flashAuthorizedByThisReport": False, "records": records})
print(f"Inventory: {path}\nIdentity: {identity}; firmware/partitions still require review.")
return 0 if identity.startswith("sargo") else 2
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Copy a checked trial into a new user directory, then open its Wayland window."""
import argparse
import hashlib
import importlib.util
import json
from pathlib import Path
import shlex
import subprocess
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--control-socket', type=Path, required=True)
parser.add_argument('--host', default='10.15.19.82')
parser.add_argument('--user', default='droidian')
args = parser.parse_args()
spec = importlib.util.spec_from_file_location('collector', ROOT/'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
base = collector.ssh_base(args.control_socket.resolve(), args.host, args.user)
subprocess.run(base[:-1]+['-O','check',base[-1]], check=True)
def run(command, **kwargs):
return subprocess.run(base+[shlex.join(command)], check=True, **kwargs)
identity = run(['getprop','ro.product.device'], capture_output=True, text=True).stdout.strip()
if identity != 'sargo':
raise RuntimeError('Expected the previously inventoried sargo device')
archive = ROOT/'out/droidian/vulpes-droidian.tar.gz'
metadata = json.loads(archive.with_suffix('.json').read_text())
with archive.open('rb') as stream:
if hashlib.file_digest(stream,'sha256').hexdigest() != metadata['sha256']:
raise RuntimeError('Local archive hash differs from its manifest')
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
remote = run(['python3','-c',
"from pathlib import Path; p=Path.home()/'vulpes-tundra-tests'/"+repr(stamp)+
"; p.mkdir(parents=True,exist_ok=False); print(p)"],
capture_output=True,text=True).stdout.strip()
with archive.open('rb') as stream:
run(['python3','-c',
"import sys,shutil; f=open(sys.argv[1],'xb'); shutil.copyfileobj(sys.stdin.buffer,f); f.close()",
remote+'/payload.tar.gz'], stdin=stream, timeout=300)
code = '''import hashlib,json,pathlib,subprocess,sys,tarfile
p=pathlib.Path(sys.argv[1]); archive=p/'payload.tar.gz'
with archive.open('rb') as stream: digest=hashlib.file_digest(stream,'sha256').hexdigest()
if digest != sys.argv[2]: raise RuntimeError('Transfer hash mismatch')
product=p/'product'; product.mkdir()
with tarfile.open(archive) as package: package.extractall(product,filter='data')
log=(p/'session.log').open('w')
proc=subprocess.Popen(['python3',str(product/'trial/session.py')],stdin=subprocess.DEVNULL,
stdout=log,stderr=subprocess.STDOUT,start_new_session=True)
print(json.dumps({'directory':str(p),'pid':proc.pid,'sha256':digest}))
'''
result = json.loads(run(['python3','-c',code,remote,metadata['sha256']],
capture_output=True,text=True,timeout=900).stdout)
result['engine'] = metadata['engine']
result['createdAt'] = stamp
path = ROOT/'logs/device/droidian-deployment.json'
path.write_text(json.dumps(result,indent=2)+'\n')
path.chmod(0o600)
print(json.dumps(result,indent=2))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Operate only the last isolated trial, using the existing authenticated SSH master."""
import argparse
import importlib.util
import json
from pathlib import Path
import shlex
import subprocess
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['status', 'collect', 'capture', 'restart', 'stop'])
parser.add_argument('--hybris-egl', action='store_true')
parser.add_argument('--software', action='store_true')
parser.add_argument('--headless', action='store_true')
parser.add_argument('--no-forkserver', action='store_true')
parser.add_argument('--trace-navigation', action='store_true')
parser.add_argument('--control-socket', type=Path, default=ROOT/'logs/device/ssh-control')
args = parser.parse_args()
if not args.control_socket.is_socket():
parser.error('SSH session unavailable. Reopen the authenticated master described in docs/SARGO.md.')
spec = importlib.util.spec_from_file_location('collector', ROOT/'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
base = collector.ssh_base(args.control_socket.resolve(), '10.15.19.82', 'droidian')
master = subprocess.run(base[:-1]+['-O', 'check', base[-1]], capture_output=True)
if master.returncode:
parser.error('SSH master disconnected. Reopen it before operating the trial.')
deployment = json.loads((ROOT/'logs/device/droidian-deployment.json').read_text())
remote = deployment['directory']
def run(code, *extra, **kwargs):
return subprocess.run(base+[shlex.join(['python3','-c',code,remote,*extra])],
check=True, timeout=180, **kwargs)
if args.action in ('stop', 'restart'):
run(r'''import os,pathlib,signal,sys,time
p=pathlib.Path(sys.argv[1]); product=p/'product'; pidfile=product/'logs/session.pid'
if pidfile.exists():
pid=int(pidfile.read_text()); proc=pathlib.Path('/proc')/str(pid)
if proc.exists():
command=(proc/'cmdline').read_bytes().split(b'\0')
if str(product/'trial/session.py').encode() not in command: raise RuntimeError('PID no longer belongs to trial')
os.kill(pid,signal.SIGTERM)
for _ in range(1500):
if not pidfile.exists(): break
time.sleep(.1)
else: raise RuntimeError('Session did not stop; left untouched')
''')
if args.action == 'restart':
for name in ('session.py', 'probe.sys.mjs'):
run("import pathlib,sys; (pathlib.Path(sys.argv[1])/'product/trial'/sys.argv[2]).write_bytes(sys.stdin.buffer.read())",
name, input=(ROOT/'droidian'/name).read_bytes())
if args.hybris_egl:
subprocess.run(['python3',str(ROOT/'tools/build-droidian-graphics.py')],check=True)
for name in ('libEGL_vulpes_hybris.so','manifest.json'):
run("import pathlib,sys;p=pathlib.Path(sys.argv[1])/'product/trial/graphics';p.mkdir(exist_ok=True);(p/sys.argv[2]).write_bytes(sys.stdin.buffer.read())",
name,input=(ROOT/'out/droidian-graphics'/name).read_bytes())
run('''import pathlib,shutil,subprocess,sys,time
p=pathlib.Path(sys.argv[1]); product=p/'product'; logs=product/'logs'
backup=logs/('attempt-'+str(time.time_ns())); backup.mkdir()
for name in ('gecko.log','server.log','test.json','home.png'):
f=logs/name
if f.exists(): shutil.move(f,backup/name)
for f in logs.glob('navigation.log*'): shutil.move(f,backup/f.name)
if (p/'session.log').exists(): shutil.move(p/'session.log',backup/'session.log')
with (p/'session.log').open('a') as output:
proc=subprocess.Popen(['python3',str(product/'trial/session.py'),*sys.argv[2:]],
stdin=subprocess.DEVNULL,stdout=output,stderr=subprocess.STDOUT,start_new_session=True)
print('Started trial supervisor',proc.pid)
''', *(['--software'] if args.software else []), *(['--headless'] if args.headless else []),
*(['--no-forkserver'] if args.no_forkserver else []),
*(['--trace-navigation'] if args.trace_navigation else []),
*(['--hybris-egl'] if args.hybris_egl else []))
if args.action == 'capture':
result=run('''import os,pathlib,subprocess,sys,time
p=pathlib.Path(sys.argv[1])/'product/logs'; shot=p/('physical-'+str(time.time_ns())+'.png')
env=dict(os.environ,DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/'+str(os.getuid())+'/bus')
result=subprocess.run(['gdbus','call','--session','--dest','org.gnome.Shell.Screenshot',
'--object-path','/org/gnome/Shell/Screenshot','--method',
'org.gnome.Shell.Screenshot.Screenshot','false','false',str(shot)],
env=env,capture_output=True,text=True,check=True,timeout=15)
if not result.stdout.startswith('(true,'): raise RuntimeError(result.stdout)
sys.stdout.buffer.write(shot.read_bytes())
''',capture_output=True)
target=ROOT/'logs/device/droidian-trial/physical-display.png'
target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(result.stdout)
print(target)
if args.action == 'status':
run('''import pathlib,sys
p=pathlib.Path(sys.argv[1])
for name in ('session.log','product/logs/gecko.log','product/logs/test.json'):
f=p/name; print(name, f.read_text(errors='replace')[-12000:] if f.exists() else 'pending')
''')
if args.action == 'collect':
target=ROOT/'logs/device/droidian-trial'
target.mkdir(exist_ok=True)
for name in ('gecko.log','server.log','test.json','home.png'):
result=run("import pathlib,sys; f=pathlib.Path(sys.argv[1])/'product/logs'/sys.argv[2]; sys.stdout.buffer.write(f.read_bytes() if f.exists() else b'')",
name,capture_output=True)
if result.stdout:
(target/name).write_bytes(result.stdout)
else:
(target/name).unlink(missing_ok=True)
result=run(r'''import json,pathlib,platform,sys
p=pathlib.Path(sys.argv[1]); product=p/'product'; logs=product/'logs'
report={'kernel':platform.release(),'architecture':platform.machine(),'attempts':{},'processes':[]}
for folder in sorted(logs.glob('attempt-*')):
report['attempts'][folder.name]={f.name:f.read_text(errors='replace')[-80000:]
for f in folder.iterdir() if f.name in ('gecko.log','test.json')}
for proc in pathlib.Path('/proc').glob('[0-9]*'):
try:
cmd=(proc/'cmdline').read_bytes().split(b'\0')
if cmd and str(product/'engines').encode() in cmd[0]:
status={k:v.strip() for k,v in (line.split(':',1) for line in (proc/'status').read_text().splitlines() if ':' in line)
if k in ('Name','State','VmRSS','Seccomp','NoNewPrivs')}
report['processes'].append({'pid':proc.name,'status':status,'wchan':(proc/'wchan').read_text()})
except OSError: pass
print(json.dumps(report,indent=2))
''',capture_output=True)
(target/'diagnostics.json').write_bytes(result.stdout)
print(target)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Mark the installed Sargo slot successful after current-boot health checks.
Uses Debian's Qualcomm boot-control utility. Never switches slots or writes a
boot image. GPT metadata is backed up before its success bits are updated.
"""
import argparse
import base64
import hashlib
import io
import json
from pathlib import Path
import subprocess
import tarfile
import tempfile
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
PACKAGE_URL = 'https://deb.debian.org/debian/pool/main/q/qbootctl/qbootctl_0.2.2-1_arm64.deb'
PACKAGE_SHA = '76b9be97107f14a79641bc274a00010aade3362b5099df5ae8b85603cbb6eb22'
SUCCESS = 1 << 54
def verify_transition(before, after):
changed = []
for copy in ('primary', 'secondary'):
if 'header' in before[copy]:
headers = [bytearray(base64.b64decode(v[copy]['header'])) for v in (before, after)]
for h in headers:
h[16:20] = b'\0'*4
h[88:92] = b'\0'*4
if headers[0] != headers[1]:
raise ValueError('GPT header fields changed')
a = base64.b64decode(before[copy]['table'])
b = base64.b64decode(after[copy]['table'])
if len(a) != len(b):
raise ValueError('Partition table length changed')
expected = bytearray(a)
for label, part in before[copy]['partitions'].items():
offset = (part['index'] - 1) * 128
if a[offset:offset+128] == b[offset:offset+128]:
continue
if not label.endswith('_a') or label[:-1]+'b' not in before[copy]['partitions']:
raise ValueError('Unexpected partition changed: '+label)
expected[offset+54] |= 0x40
if copy == 'primary': changed.append(label)
if bytes(expected) != b:
raise ValueError('A field other than a success bit changed')
if not after['primary']['partitions']['boot_a']['attributes'] & SUCCESS:
raise ValueError('Slot A was not marked successful')
return changed
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build', type=Path, required=True)
parser.add_argument('--mark-successful', action='store_true')
args = parser.parse_args()
folder = args.build.resolve()
build = json.loads((folder/'build.json').read_text())
install = json.loads((folder/'install-report.json').read_text())
if not install['completed'] or install['imageSha256'] != build['imageSha256']:
raise ValueError('A successful installation report is required')
ssh = ['ssh', '-F', '/dev/null', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
'-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
'-o', 'HostKeyAlgorithms=+ssh-rsa', '-o', 'UserKnownHostsFile='+str(folder/'known_hosts'),
'-i', str(folder/'client-key'), '-p', '2222', 'root@10.15.19.82']
def remote(command, data=None):
return subprocess.run(ssh+[command], input=data, capture_output=True,
check=True, timeout=45).stdout
config = {'serialSha256':build['serialSha256'], 'imageSha256':build['imageSha256'],
'bytes':(folder/build['image']).stat().st_size}
inspect = (ROOT/'tools/read-sargo-gpt.py').read_text()
health = '''
from pathlib import Path
import hashlib,json,subprocess
cmdline = dict(x.split('=',1) for x in Path('/proc/cmdline').read_text().split() if '=' in x)
assert cmdline.get('androidboot.slot_suffix') == '_a'
assert hashlib.sha256(cmdline['androidboot.serialno'].encode()).hexdigest() == config['serialSha256']
with open('/dev/disk/by-partlabel/boot_a','rb') as f:
assert hashlib.sha256(f.read(config['bytes'])).hexdigest() == config['imageSha256']
gaia = json.loads(Path('/opt/vulpes/logs/test.json').read_text())
assert gaia['passed'] and all(gaia['checks'].values())
assert gaia['bootId'] == Path('/proc/sys/kernel/random/boot_id').read_text().strip()
for units in [('tundra-hal','tundra-display','tundra-radio')]:
subprocess.run(['systemctl','is-active','--quiet',*units],check=True)
assert not subprocess.check_output(['systemctl','--failed','--no-legend','--no-pager']).strip()
'''
def snapshot():
code = 'config='+repr(config)+'\n'+health+'\n'+inspect
return json.loads(remote('chroot /proc/1/root /usr/bin/python3 -', code.encode()))
before = snapshot()
attr = before['primary']['partitions']['boot_a']['attributes']
if not attr & (1 << 50) or attr & (1 << 55):
raise ValueError('Slot A must be active and bootable')
print('Installed image, current Gaia boot, services and mirrored GPT checked.')
if not args.mark_successful:
print('Review only. Successful:', bool(attr & SUCCESS)); return
from datetime import datetime, timezone
evidence = folder/('slot-finalization-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
evidence.mkdir(mode=0o700)
(evidence/'gpt-before.json').write_text(json.dumps(before,indent=2)+'\n')
if not attr & SUCCESS:
package = urllib.request.urlopen(PACKAGE_URL, timeout=30).read()
if hashlib.sha256(package).hexdigest() != PACKAGE_SHA:
raise ValueError('qbootctl package hash mismatch')
with tempfile.TemporaryDirectory() as directory:
deb = Path(directory)/'qbootctl.deb'; deb.write_bytes(package)
data = subprocess.check_output(['ar','p',str(deb),'data.tar.xz'])
with tarfile.open(fileobj=io.BytesIO(data),mode='r:xz') as archive:
binary = archive.extractfile('./usr/bin/qbootctl').read()
remote('cat > /proc/1/root/run/tundra-qbootctl && chmod 700 /proc/1/root/run/tundra-qbootctl', binary)
try:
output = remote('chroot /proc/1/root /run/tundra-qbootctl -m a')
(evidence/'qbootctl.txt').write_bytes(output)
finally:
remote('rm -f /proc/1/root/run/tundra-qbootctl')
after = snapshot()
(evidence/'gpt-after.json').write_text(json.dumps(after,indent=2)+'\n')
changed = verify_transition(before, after)
(evidence/'result.json').write_text(json.dumps({'passed':True,'bootId':after['bootId'],
'imageSha256':build['imageSha256'],'packageSha256':PACKAGE_SHA,
'metadataChanged':changed,'partitionsFlashed':[]},indent=2)+'\n')
print('Slot A marked successful; metadata checked:', evidence)
if __name__ == '__main__': main()

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read readiness, never dial, send messages, enter PINs or open camera nodes."""
import json
from pathlib import Path
import subprocess
import time
def command(args):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=12)
if result.returncode:
return None
return result.stdout
except (OSError, subprocess.TimeoutExpired):
return None
def bus(*args):
text = command(['busctl', '--system', '--json=short', *args])
try:
return json.loads(text)['data'] if text is not None else None
except (ValueError, KeyError, TypeError):
return None
def properties(interface):
data = bus('call', 'org.ofono', '/ril_0', interface, 'GetProperties')
if isinstance(data, list) and len(data) == 1:
data = data[0]
return data if isinstance(data, dict) else {}
def selected(values, names):
# GetProperties returns identifiers too: none are copied into the report.
return {key: values[key].get('data') for key in names
if key in values and isinstance(values[key], dict)}
def inspect():
native = 'tundra.rescue=1' in Path('/proc/cmdline').read_text()
report = {'recordedAtUnix': time.time(), 'environment': 'tundra' if native else 'reference',
'scope': 'Read-only readiness, not functional call/SMS/audio/camera qualification',
'services': {}}
for service in ('tundra-hal', 'tundra-wifi', 'NetworkManager', 'ofono', 'ModemManager'):
report['services'][service] = (command(['systemctl', 'is-active', service]) or 'inactive').strip()
modem = selected(properties('org.ofono.Modem'), ('Powered', 'Online', 'Interfaces'))
sim = selected(properties('org.ofono.SimManager'), ('Present', 'PinRequired', 'LockedPins'))
registration = selected(properties('org.ofono.NetworkRegistration'), ('Status', 'Technology', 'Strength'))
interfaces = modem.get('Interfaces') or []
report['telephony'] = {'modem': modem, 'sim': sim, 'registration': registration,
'voiceInterface': 'org.ofono.VoiceCallManager' in interfaces,
'smsInterface': 'org.ofono.MessageManager' in interfaces,
'outgoingOperationsPerformed': False}
report['audio'] = {}
# Run as the session user for access to their PulseAudio server.
for kind in ('sinks', 'sources'):
raw = command(['pactl', '--format=json', 'list', kind])
try:
items = json.loads(raw) if raw else None
if items is None:
short = command(['pactl', 'list', 'short', kind])
items = [] if short is not None else None
for line in (short or '').splitlines():
fields = line.split('\t')
if len(fields) >= 5:
items.append({'name': fields[1], 'state': fields[-1],
'driver': fields[2]})
if items is None:
report['audio'][kind] = None
continue
report['audio'][kind] = [{key: item.get(key) for key in
('name', 'state', 'mute', 'active_port', 'driver')}
for item in items]
except (TypeError, ValueError):
report['audio'][kind] = []
report['camera'] = {
'halProbeOnly': True,
'applicationInstalled': Path('/usr/bin/droidian-camera').exists(),
'captureTested': False,
}
report['battery'] = {}
for name in ('capacity', 'status'):
try:
report['battery'][name] = Path('/sys/class/power_supply/battery', name).read_text().strip()
except OSError:
pass
return report
if __name__ == '__main__':
print(json.dumps(inspect(), indent=2))

View file

@ -0,0 +1,70 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Inspect a running native RAM trial. Execute inside its main root as root."""
import json
import os
from pathlib import Path
import subprocess
import time
def inspect():
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('This inspector is for a native RAM trial')
result={'recordedAtUnix':time.time(),'uptime':Path('/proc/uptime').read_text().strip()}
commands={
'failedUnits':['systemctl','--failed','--no-legend','--no-pager'],
'activeUnits':['systemctl','is-active','tundra-hal','tundra-display','systemd-logind','user@32011'],
'displayRestarts':['systemctl','show','tundra-display','-p','NRestarts','--value'],
'kernel':['uname','-r'],
'halPid':['lxc-info','-n','tundra-hal','-pH'],
}
for name,command in commands.items():
process=subprocess.run(command,capture_output=True,text=True,timeout=10)
result[name]={'code':process.returncode,'stdout':process.stdout,'stderr':process.stderr}
kernel = subprocess.run(['dmesg'], capture_output=True, text=True, timeout=10)
result['kernelFaults'] = [line for line in kernel.stdout.splitlines()
if any(marker in line for marker in
('Internal error: Oops', 'Kernel panic', 'Kernel BUG', 'BUG:'))]
result['memoryKills'] = [line for line in kernel.stdout.splitlines()
if any(marker in line for marker in
('lowmemorykiller: Killing', 'Out of memory: Kill', 'oom-kill:', 'Killed process'))]
result['kernelLogReadable'] = kernel.returncode == 0
result['pid1']=Path('/proc/1/cmdline').read_text().replace('\0',' ')
result['identity']=Path('/etc/os-release').read_text()
result['mounts']=Path('/proc/1/mounts').read_text()
result['memory']=Path('/proc/meminfo').read_text()
pid=result['halPid']['stdout'].strip()
result['halCgroups']=Path('/proc',pid,'cgroup').read_text() if pid.isdigit() else None
result['halMemoryLimits']={str(p):p.read_text().strip() for p in Path('/sys/fs/cgroup').rglob('memory.max') if 'lxc.payload.tundra-hal' in str(p)}
result['gaia']=json.loads(Path('/opt/vulpes/logs/test.json').read_text())
processes=[]
for path in Path('/proc').glob('[0-9]*/cmdline'):
try:
command=path.read_text().replace('\0',' ').strip()
if not command:continue
status=dict(line.split(':',1) for line in (path.parent/'status').read_text().splitlines() if ':' in line)
processes.append({'pid':int(path.parent.name),'command':command,
'uid':status.get('Uid','').strip(),'seccomp':status.get('Seccomp','').strip(),
'capabilities':status.get('CapEff','').strip()})
except (OSError,ProcessLookupError):continue
result['processes']=processes
result['checks']={
'noKernelFaults':result['kernelLogReadable'] and not result['kernelFaults'],
'noMemoryKills':result['kernelLogReadable'] and not result['memoryKills'],
'tundraPid1':'--unit=tundra.target' in result['pid1'],
'noFailedUnits':result['failedUnits']['code']==0 and not result['failedUnits']['stdout'].strip(),
'servicesActive':result['activeUnits']['code']==0 and result['activeUnits']['stdout'].split()==['active']*4,
'noDisplayRestart':result['displayRestarts']['stdout'].strip()=='0',
'gaia':result['gaia']['passed'],
'halLegacyDeviceGuard':bool(result['halCgroups'] and ':devices:/hal' in result['halCgroups']),
'halMemoryBounded':'536870912' in result['halMemoryLimits'].values(),
'noAndroidAppRuntime':not any(p['command'].split()[0].rsplit('/',1)[-1] in ('zygote','zygote64','app_process','app_process64','phosh') for p in processes),
}
return result
if __name__=='__main__':
result=inspect()
print(json.dumps(result,indent=2))
raise SystemExit(0 if all(result['checks'].values()) else 1)

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Review, install or restore a qualified private Sargo boot image.
The system and data files must already be staged and tested on the inventoried
phone. Only boot_a can be written. No userdata formatting, slot switch or erase.
"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import subprocess
from tundra import ROOT, digest, now, save
def load(name, path):
spec=importlib.util.spec_from_file_location(name,path)
value=importlib.util.module_from_spec(spec);spec.loader.exec_module(value)
return value
def checked_build(folder):
folder=Path(folder).resolve()
build=json.loads((folder/'build.json').read_text())
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')
or not build.get('persistentState') or build.get('watchdogSeconds') is not None
or build.get('interactiveDiagnostics')):
raise ValueError('Expected a qualified persistent image without interactive tests or a deadline')
image=(folder/build['image']).resolve();image.relative_to(folder)
if digest(image)!=build['imageSha256'] or qualification.get('imageSha256')!=build['imageSha256']:
raise ValueError('Boot image provenance mismatch')
evidence=qualification.get('evidenceSha256')
if not evidence:raise ValueError('Qualification evidence missing')
for name,expected in evidence.items():
path=(folder/name).resolve();path.relative_to(folder)
if digest(path)!=expected:raise ValueError('Qualification evidence changed: '+name)
persistence=qualification.get('persistence',{})
if not (persistence.get('passed') and persistence.get('distinctBoots')
and persistence.get('sameGaiaSetting') and persistence.get('restored')):
raise ValueError('A real persistence round trip is required')
load('rescue_check',ROOT/'tools/test-rescue-boot.py').verify_sources(folder,build)
return build,qualification,image
def flash_boot_a(serial, image):
# Keep the write surface separate from the RAM boot tools.
subprocess.run(['fastboot','-s',serial,'flash','boot_a',str(image)],check=True,timeout=90)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
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')
plan,_=boot.checked_plan()
if args.restore:
# Recovery must still work if the candidate or its proof was damaged.
image=Path(plan['backupDirectory'])/'boot_a.img'
build={'serialSha256':plan['serialSha256']}
else:
build,qualification,image=checked_build(args.build)
if plan['serialSha256']!=build['serialSha256']:raise ValueError('Build targets another phone')
print('Private Sargo installation plan')
print('Boot image:',image)
if not args.restore:
print('System file: /tundra/images/'+build['systemImage']['imageSha256']+'.ext4')
print('Data file: /tundra/data/'+build['persistentState']['uuid']+'.ext4')
print('Only boot_a may be written. Existing files, userdata and slot B are retained.')
if not (args.flash or args.restore):
print('Review only. Use --flash or --restore explicitly with the inventoried phone in Fastboot.')
return
serial=boot.find_fastboot(build['serialSha256'],15)
identity=boot.check_device(serial,build['serialSha256'])
report={'recordedAt':now(),'operation':'restore' if args.restore else 'install',
'partition':'boot_a','imageSha256':digest(image),'identity':identity,
'erasesSent':False,'slotChanged':False,'completed':False}
path=args.build/('restore-report.json' if args.restore else 'install-report.json')
save(path,report)
flash_boot_a(serial,image)
report['completed']=True;save(path,report)
print('boot_a written. Select Start in Fastboot to boot. No other partition was flashed.')
if not args.restore:
print('After Gaia passes startup checks, finalize the A/B success flag:')
print(' python3 tundra/tools/finalize-sargo-slot.py --build '+str(args.build)+' --mark-successful')
print('Do not repeat test reboots before this step: the bootloader has a limited retry count.')
if __name__=='__main__':main()

View file

@ -0,0 +1,14 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Normalize guest ownership under fakeroot, then create ext4 without host root."""
import os
from pathlib import Path
import subprocess
import sys
if not os.environ.get('FAKEROOTKEY'):
raise SystemExit('Run through fakeroot; never chown the staging tree as real root.')
root=Path(sys.argv[1])
for base, dirs, files in os.walk(root):
os.lchown(base,0,0)
for name in dirs+files: os.lchown(Path(base)/name,0,0)
subprocess.run(['mke2fs','-q','-t','ext4','-F','-d',str(root),sys.argv[2]],check=True)

View file

@ -0,0 +1,38 @@
# SPDX-License-Identifier: MPL-2.0
"""Describe the dedicated ext4 data file used by a private Sargo installation."""
import hashlib
import json
import re
import uuid
from pathlib import Path
FEATURES = 'none,has_journal,ext_attr,dir_index,filetype,extent,sparse_super,large_file,uninit_bg,dir_nlink,extra_isize'
def digest(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def validate_manifest(path):
path = Path(path).resolve()
value = json.loads(path.read_text())
if value.get('schema') != 1 or value.get('device') != 'sargo':
raise ValueError('Expected a Sargo data-image manifest')
identifier = value.get('uuid')
if not isinstance(identifier, str) or str(uuid.UUID(identifier)) != identifier:
raise ValueError('Invalid data filesystem UUID')
if value.get('image') != identifier+'.ext4':
raise ValueError('Data image filename does not match its UUID')
size = value.get('bytes')
if type(size) is not int or not 512*1024**2 <= size <= 8*1024**3 or size % 4096:
raise ValueError('Invalid data image size')
for name in ('imageSha256', 'systemImageSha256'):
if not re.fullmatch('[a-f0-9]{64}', str(value.get(name, ''))):
raise ValueError('Invalid '+name)
image = path.parent/value['image']
if image.is_symlink() or image.stat().st_size != size or digest(image) != value['imageSha256']:
raise ValueError('Initial data image changed')
if value.get('filesystemCheckPassed') is not True:
raise ValueError('Data filesystem has not passed verification')
return value

View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage the common Gaia host and a checked ARM64 engine for a Droidian trial."""
import hashlib
import importlib.util
import json
from pathlib import Path
import shutil
import tarfile
import subprocess
import sys
TUNDRA = Path(__file__).resolve().parents[1]
PROJECT = TUNDRA.parent
STAGE = TUNDRA / 'out/droidian/product'
def main():
STAGE.mkdir(parents=True, exist_ok=True)
for name in ('downloads', 'engines'):
(STAGE/name).mkdir(exist_ok=True)
spec = importlib.util.spec_from_file_location('fetch', PROJECT/'tools/fetch-engine.py')
fetch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fetch)
fetch.ROOT = STAGE
lock = json.loads((PROJECT/'engine-lock.json').read_text())
# Desktop qualification does not qualify an ARM64 runtime on Droidian.
for field in ('qualificationReport', 'gaiaBoots'):
lock.pop(field, None)
version = lock['version']
base = f'https://archive.mozilla.org/pub/firefox/releases/{version}/'
relative = f'linux-aarch64/en-US/firefox-{version}.tar.xz'
sums = fetch.read_url(base+'SHA512SUMS').decode()
expected = next(line.split()[0] for line in sums.splitlines()
if len(line.split()) == 2 and line.split()[1] == relative)
lock.update(archive=base+relative, sha512=expected,
checksumSource=base+'SHA512SUMS', architecture='aarch64')
(STAGE/'downloads/SHA512SUMS').write_text(sums)
fetch.install(lock)
(STAGE/'engines'/version/'engine.json').write_text(json.dumps(lock, indent=2)+'\n')
for name in ('assets', 'host', 'services', 'adapters', 'overrides',
'gaia/shared', 'gaia/apps/system'):
target = STAGE/name
if target.exists():
shutil.rmtree(target)
shutil.copytree(PROJECT/name, target,
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests'):
(STAGE/name).mkdir(exist_ok=True)
for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
shutil.copy2(PROJECT/'tools'/name, STAGE/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', STAGE/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', STAGE/'release.json')
(STAGE/'engine-lock.json').write_text(json.dumps(lock, indent=2)+'\n')
if (STAGE/'trial').exists():
shutil.rmtree(STAGE/'trial')
shutil.copytree(TUNDRA/'droidian', STAGE/'trial',
ignore=shutil.ignore_patterns('__pycache__'))
subprocess.run([sys.executable,str(TUNDRA/'tools/build-droidian-graphics.py')],check=True)
for name in ('libEGL_vulpes_hybris.so','manifest.json'):
shutil.copy2(TUNDRA/'out/droidian-graphics'/name,STAGE/'trial/graphics'/name)
archive = STAGE.parent/'vulpes-droidian.tar.gz'
with tarfile.open(archive, 'w:gz', compresslevel=1) as tar:
for item in sorted(STAGE.iterdir()):
if item.name != 'downloads':
tar.add(item, arcname=item.name)
with archive.open('rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
(archive.with_suffix('.json')).write_text(json.dumps({
'archive': archive.name, 'sha256': digest, 'engine': lock,
'scope': 'Unprivileged trial on the existing Droidian Wayland session; no boot image.'
}, indent=2)+'\n')
print(archive, digest, flush=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Compare raw boot backups to the installed-kernel diagnostic, without device writes."""
import argparse
import hashlib
import importlib.util
import json
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
from boot_backup import verify
from tundra import ROOT, checked_source, digest, now, save
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--backup', required=True, type=Path)
args = parser.parse_args()
backup = args.backup.resolve()
verify(backup)
spec = importlib.util.spec_from_file_location('collector', ROOT/'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
ssh = collector.ssh_base(ROOT/'logs/device/ssh-control', '10.15.19.82', 'droidian')
code = """import json,subprocess
def prop(key): return subprocess.check_output(['getprop',key],text=True).strip()
print(json.dumps({k:prop(k) for k in ('ro.product.device','ro.boot.slot_suffix','ro.boot.serialno')}))
"""
device = json.loads(subprocess.check_output(ssh+[shlex.join(['python3','-c',code])], timeout=20))
if device['ro.product.device'] != 'sargo' or device['ro.boot.slot_suffix'] != '_a' or not device['ro.boot.serialno']:
raise ValueError('Expected the inventoried Sargo running slot A')
build = json.loads((ROOT/'out/sargo-raw-diagnostic/build.json').read_text())
if build.get('rawBackupManifestSha256') != digest(backup/'manifest.json'):
raise ValueError('Diagnostic was not built from this raw backup')
slots = {}
with tempfile.TemporaryDirectory() as temp:
for slot in ('a','b'):
out = Path(temp)/slot
result = subprocess.check_output([sys.executable,
str(checked_source('aosp-mkbootimg')/'unpack_bootimg.py'),
'--boot_img',str(backup/f'boot_{slot}.img'),'--out',str(out),
'--format=mkbootimg'], text=True)
args = shlex.split(result)
fields = dict(zip(args[::2],args[1::2]))
slots[slot] = {'kernelSha256':digest(out/'kernel'),
'ramdiskSha256':digest(out/'ramdisk'),
'header':{k:v for k,v in fields.items() if k not in ('--kernel','--ramdisk','--dtb','--cmdline')},
'matchesDiagnosticKernel':digest(out/'kernel') == build['kernelSha256']}
if slot == 'a':
for key,value in slots[slot]['header'].items():
if build['header'].get(key) != value:
raise ValueError('Slot A header differs from the diagnostic: '+key)
if build['header']['--cmdline'] != fields['--cmdline']+' rdinit=/init tundra.usb=1':
raise ValueError('Diagnostic command line differs from the inventoried boot')
if not slots['a']['matchesDiagnosticKernel']:
raise ValueError('Slot A kernel differs from the diagnostic')
image = ROOT/'out/sargo-raw-diagnostic'/build['file']
if digest(image) != build['imageSha256']:
raise ValueError('Diagnostic image changed')
report = {'recordedAt':now(),'device':'sargo','activeSlot':'a',
'serialSha256':hashlib.sha256(device['ro.boot.serialno'].encode()).hexdigest(),
'backupDirectory':str(backup),'backupManifestSha256':digest(backup/'manifest.json'),
'slots':slots,'diagnosticImage':str(image.relative_to(ROOT)),
'diagnosticImageSha256':digest(image),'diagnosticBuildSha256':digest(image.parent/'build.json'),
'rawBackupVerified':True,'restorationTested':False,'hardwareBootTested':False,
'flashReady':False}
path = ROOT/'logs/device/boot-backup-qualification.json'
save(path,report)
validation = json.loads((ROOT/'VALIDATION.json').read_text())
validation['deviceInventory']['rawPartitionsBackedUp'] = True
validation['bootBackup'] = {'report':str(path.relative_to(ROOT)),
'reportSha256':digest(path),'verified':True,'restorationTested':False}
save(ROOT/'VALIDATION.json',validation)
print('Six partitions verified; slot A kernel and header match the diagnostic.')
print('Slot B has different contents and is not a qualified fallback.')
print(path)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run inside a native RAM boot; accept a temporary Wi-Fi profile on stdin.
The credential stays in /run, is removed after activation, and is never included
in the JSON report. This qualifies the system path, not Gaia connection controls.
"""
import json
import ipaddress
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
import uuid
def nm(*args, timeout=45):
return subprocess.run(['nmcli', '--wait', '35', *args], capture_output=True,
text=True, timeout=timeout, env={**os.environ, 'LC_ALL':'C'})
def main():
if os.getuid() or 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('Run as root inside the temporary native system')
profile = json.loads(sys.stdin.read(16384))
ssid = profile['ssid']
if not isinstance(ssid, str) or not 1 <= len(ssid.encode()) <= 32:
raise ValueError('Invalid SSID')
security = profile['keyManagement']
if security not in ('', '--', 'wpa-psk', 'sae'):
raise ValueError('Unsupported authentication')
password = profile.get('psk', '')
if not isinstance(password, str) or any(c in password for c in '\r\n\x00'):
raise ValueError('Invalid password')
report = {'scope':'Native WLAN scan, ephemeral connection and DNS; no Gaia controls', 'checks':{}, 'addressFamilies':{}}
uid = str(uuid.uuid4())
target = Path('/run/NetworkManager/system-connections/tundra-network-check.nmconnection')
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
text = f'[connection]\nid=Tundra temporary network check\nuuid={uid}\ntype=wifi\ninterface-name=wlan0\nautoconnect=false\n[wifi]\nmode=infrastructure\ncloned-mac-address=permanent\nssid='+''.join(str(b)+';' for b in ssid.encode())+'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n'
if security in ('wpa-psk','sae'):
escaped = password.replace('\\', '\\\\').replace('\t','\\t').replace(' ', '\\s')
text += f'[wifi-security]\nkey-mgmt={security}\npsk={escaped}\n'
previous = os.umask(0o077)
try:
rows = nm('-g','SIGNAL','device','wifi','list','ifname','wlan0','--rescan','yes')
report['checks']['scanCompleted'] = rows.returncode == 0
report['visibleNetworks'] = len(rows.stdout.splitlines()) if rows.returncode == 0 else 0
report['checks']['networksFound'] = report['visibleNetworks'] > 0
target.write_text(text)
report['checks']['profileLoaded'] = nm('connection','load',str(target)).returncode == 0
activated = nm('connection','up','uuid',uid)
report['checks']['connected'] = activated.returncode == 0
if activated.returncode:
report['activationExitCode'] = activated.returncode
for family in (4, 6):
addresses = nm('--escape','no','-g',f'IP{family}.ADDRESS','device','show','wlan0')
usable = False
if addresses.returncode == 0:
for line in addresses.stdout.splitlines():
try:
address = ipaddress.ip_interface(line.strip()).ip
usable |= not (address.is_link_local or address.is_loopback or address.is_unspecified)
except ValueError:
pass
report['addressFamilies']['ipv'+str(family)] = usable
report['checks']['ipAddress'] = any(report['addressFamilies'].values())
try:
socket.setdefaulttimeout(8)
resolved = socket.getaddrinfo('example.org',443,type=socket.SOCK_STREAM)
report['checks']['dns'] = bool(resolved)
with socket.create_connection(('example.org',443),timeout=8):
report['checks']['tcp443'] = True
except OSError:
report['checks'].setdefault('dns',False)
report['checks']['tcp443'] = False
report['connectedAtUnix'] = time.time()
finally:
# The active connection continues for the test; its secret is no longer
# present in a keyfile and all NM state is destroyed at the RAM reboot.
target.unlink(missing_ok=True)
os.umask(previous)
report['passed'] = all(report['checks'].values())
print(json.dumps(report,indent=2))
return 0 if report['passed'] else 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,76 @@
# SPDX-License-Identifier: MPL-2.0
"""Read verified newc ramdisks, resolving hardlinks without extracting secrets."""
import gzip
from pathlib import PurePosixPath
import stat
import subprocess
import re
import os
def read_newc(data):
if data[:2] == b'\x1f\x8b':
data = gzip.decompress(data)
entries, payloads, offset = {}, {}, 0
while offset + 110 <= len(data):
header = data[offset:offset+110]
if header[:6] != b'070701':
raise ValueError('Expected newc ramdisk')
fields = [int(header[6+i*8:14+i*8],16) for i in range(13)]
inode, mode, uid, gid, links, mtime, size, major, minor, rmajor, rminor, namesize, check = fields
name = data[offset+110:offset+110+namesize-1].decode()
start = (offset+110+namesize+3)&~3
offset = (start+size+3)&~3
if name == 'TRAILER!!!':
break
path = PurePosixPath(name)
if path.is_absolute() or '..' in path.parts or offset > len(data):
raise ValueError('Invalid ramdisk entry')
if name == '.':
continue
key = (major,minor,inode)
payload = data[start:start+size]
entries[str(path)] = {'mode':mode,'data':payload,'key':key,'links':links}
if stat.S_ISREG(mode) and payload:
payloads[key] = payload
for entry in entries.values():
if stat.S_ISREG(entry['mode']) and entry['links'] > 1 and not entry['data']:
entry['data'] = payloads.get(entry['key'],b'')
return entries
def resolve(entries, name):
import posixpath
seen = set()
while stat.S_ISLNK(entries[name]['mode']):
if name in seen:
raise ValueError('Cyclic ramdisk link')
seen.add(name)
link = entries[name]['data'].decode()
name = posixpath.normpath(link.lstrip('/') if link.startswith('/') else posixpath.join(posixpath.dirname(name),link))
if name.startswith('../'):
raise ValueError('Link escapes ramdisk')
return entries[name]['data']
def select_helpers(entries, scratch, extra=()):
"""Copy only explicit programs and their ELF dependencies, never /etc or /root."""
selected = {}
queue = ['bin/busybox','sbin/dropbear','sbin/dmsetup','lib/aarch64-linux-gnu/libnss_files.so.2', *extra]
while queue:
name = queue.pop()
if name in selected:
continue
content = resolve(entries,name)
selected[name] = content
probe = scratch/'elf';probe.write_bytes(content)
info = subprocess.check_output(['readelf','-ld',str(probe)],text=True,env={**os.environ, 'LC_ALL':'C'})
interpreter = re.search(r'Requesting program interpreter: ([^\]]+)',info)
if interpreter:
queue.append(interpreter.group(1).lstrip('/'))
for library in re.findall(r'\(NEEDED\).*\[([^\]]+)\]',info):
found = next((base+library for base in ('lib/aarch64-linux-gnu/','usr/lib/aarch64-linux-gnu/','lib/','usr/lib/') if base+library in entries),None)
if found is None:
raise ValueError('Missing ramdisk library: '+library)
queue.append(found)
return selected

View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read and validate both GPT copies on the inventoried 64 GB Sargo; no writes."""
import base64,json,struct,zlib
from pathlib import Path
def read_gpt():
result={}
with open('/dev/mmcblk0','rb',buffering=0) as disk:
for name,lba in [('primary',1),('secondary',122142719)]:
disk.seek(lba*512);h=disk.read(512)
assert h[:8]==b'EFI PART'
length,crc=struct.unpack_from('<II',h,12)
assert 92<=length<=512
check=bytearray(h[:length]);check[16:20]=b'\0'*4
assert zlib.crc32(check)==crc
current,other,first,last=struct.unpack_from('<QQQQ',h,24)
assert current==lba and {current,other}=={1,122142719}
entries,count,size,crc=struct.unpack_from('<QIII',h,72)
assert count==72 and size==128
disk.seek(entries*512);table=disk.read(count*size)
assert len(table)==count*size and zlib.crc32(table)==crc
parts={}
for i in range(count):
e=table[i*size:(i+1)*size]
label=e[56:128].decode('utf-16le').rstrip('\0')
if label:parts[label]={'index':i+1,'attributes':struct.unpack_from('<Q',e,48)[0]}
result[name]={'headerLba':lba,'entriesLba':entries,'header':base64.b64encode(h).decode(),'table':base64.b64encode(table).decode(),'partitions':parts}
assert result['primary']['table']==result['secondary']['table']
result['bootId']=Path('/proc/sys/kernel/random/boot_id').read_text().strip()
return result
print(json.dumps(read_gpt()))

45
tundra/tools/read-usb.py Normal file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read the Tundra diagnostic protocol on an explicitly selected Linux USB device."""
import argparse
import json
import os
from pathlib import Path
import re
import sys
from tundra import ROOT, now, save
from usb_protocol import exchange
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--device',required=True,help='/dev/bus/usb/BBB/DDD, determined after manual connection')
parser.add_argument('--stdout',action='store_true',help='Emit JSON only; do not create a report file')
parser.add_argument('--check-invalid',action='store_true',help='Also verify rejection of an unknown command')
args=parser.parse_args()
if not re.fullmatch(r'/dev/bus/usb/[0-9]{3}/[0-9]{3}',args.device):parser.error('Expected an explicit usbfs device path')
selected=Path(args.device)
identity=None
for entry in Path('/sys/bus/usb/devices').iterdir():
try:
path=Path('/dev/bus/usb')/f'{int((entry/"busnum").read_text()):03d}'/f'{int((entry/"devnum").read_text()):03d}'
if path==selected:
identity={key:(entry/key).read_text().strip() for key in ('idVendor','idProduct','serial')}
except (FileNotFoundError,NotADirectoryError):continue
if identity!={'idVendor':'1d6b','idProduct':'0104','serial':'TUNDRA-DIAG'}:
raise SystemExit('Selected device is not the Tundra development diagnostic; no commands sent.')
os.umask(0o077)
results=exchange(selected,('status','version','mounts','invalid') if args.check_invalid else ('status','version','mounts'))
if args.stdout:
print(json.dumps(results));return
path=ROOT/'logs/device'/('usb-'+now().replace(':','-')+'.json')
save(path,{'recordedAt':now(),'identity':identity,'readOnly':True,'results':results})
print(json.dumps(results,indent=2));print('Saved:',path)
if __name__=='__main__':
try:
main()
except (OSError,ValueError,RuntimeError) as error:
print('Tundra USB:',error,file=sys.stderr)
sys.exit(1)

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Connect to a private RAM trial using only its pinned development key."""
import argparse
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
from tundra import digest, save, now
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
parser.add_argument('--stream',action='store_true',help='Keep a diagnostic command open until interrupted')
parser.add_argument('--collect',action='store_true',help='Collect boot, services and Gaia reports, excluding credentials')
parser.add_argument('command',nargs=argparse.REMAINDER,help='Command in the minimal rescue root; use chroot /proc/1/root for the native system')
args=parser.parse_args();folder=args.build.resolve();os.umask(0o077)
build=json.loads((folder/'build.json').read_text())
if digest(folder/build['image'])!=build['imageSha256']:
raise ValueError('Trial image changed')
if not (folder/'boot-report.json').is_file():
raise ValueError('This image has not been sent with the checked boot tool')
ssh=['ssh','-F','/dev/null','-o','BatchMode=yes','-o','IdentitiesOnly=yes',
'-o','ConnectTimeout=5','-o','ServerAliveInterval=5','-o','ServerAliveCountMax=6',
'-o','StrictHostKeyChecking=yes','-o','HostKeyAlgorithms=+ssh-rsa',
'-o','UserKnownHostsFile='+str(folder/'known_hosts'),'-i',str(folder/'client-key'),
'-p','2222','root@10.15.19.82']
if args.collect:
commands={
'pid1':'cat /proc/1/cmdline',
'mounts':'cat /proc/1/mounts',
'bootLog':'cat /proc/1/root/run/boot.log',
'identity':'cat /proc/1/root/etc/os-release',
'services':'chroot /proc/1/root /usr/bin/systemctl --failed --no-pager',
'journal':'chroot /proc/1/root /usr/bin/journalctl -b -u tundra-hal -u tundra-display --no-pager -n 200',
'lxc':'cat /proc/1/root/run/tundra/lxc.log',
'gaia':'cat /proc/1/root/opt/vulpes/logs/test.json',
'gecko':'tail -100 /proc/1/root/opt/vulpes/logs/gecko.log',
}
report={'recordedAt':now(),'imageSha256':build['imageSha256'],'readOnlyCollection':True,'checks':{}}
for name,command in commands.items():
result=subprocess.run(ssh+[command],capture_output=True,text=True,timeout=30)
report['checks'][name]={'returncode':result.returncode,'stdout':result.stdout,'stderr':result.stderr}
output=folder/('collection-'+now().replace(':','-')+'.json');save(output,report);print(output)
return
if not args.command:parser.error('Use --collect or provide a command')
result=subprocess.run(ssh+[shlex.join(args.command)],timeout=None if args.stream else 60)
sys.exit(result.returncode)
if __name__=='__main__':main()

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Resolve the upstream Gonk reference manifest; no source checkout or build."""
import argparse
import concurrent.futures
import copy
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from tundra import ROOT, checked_source, flatten_manifest, locked, now, save
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=("sargo", "emulator-10"), default="sargo")
parser.add_argument("--jobs", type=int, default=4)
args = parser.parse_args()
if not 1 <= args.jobs <= 8:
parser.error("Use 1 to 8 network workers")
folder = checked_source("capyloon-manifests")
projects = flatten_manifest(folder, args.target + ".xml")
destination = ROOT / "out" / args.target
report_file = destination / "resolved-reference.json"
manifest_file = destination / "reference-pinned.xml"
# Never leave an earlier, apparently valid manifest after a failed resolution.
manifest_file.unlink(missing_ok=True)
prior = json.loads(report_file.read_text()) if report_file.exists() else {}
cache = {(x["url"], x["revision"]): x for x in prior.get("projects", []) if x.get("commit")}
def resolve(project):
item = dict(project)
key = (item["url"], item["revision"])
if key in cache:
return dict(item, commit=cache[key]["commit"])
ref = item["revision"]
if re.fullmatch(r"[0-9a-f]{40}", ref or ""):
# Already pinned upstream; the object will be checked when fetched.
return dict(item, commit=ref, verification="upstream-pin; object not fetched")
patterns = [ref, ref + "^{}"] if ref.startswith("refs/") else ["refs/heads/" + ref, "refs/tags/" + ref, "refs/tags/" + ref + "^{}"]
try:
p = subprocess.run(["git", "ls-remote", item["url"], *patterns],
env=dict(os.environ, GIT_TERMINAL_PROMPT="0"),
capture_output=True, text=True, timeout=45, check=True)
refs = [line.split() for line in p.stdout.splitlines()]
if not refs:
raise ValueError("Upstream revision does not exist")
commit = next((sha for sha, name in refs if name.endswith("^{}")), refs[0][0])
if not re.fullmatch(r"[0-9a-f]{40}", commit):
raise ValueError("Invalid upstream object ID")
return dict(item, commit=commit)
except (ValueError, subprocess.SubprocessError) as error:
detail = getattr(error, "stderr", None) or str(error)
return dict(item, error=detail[-1500:])
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool:
futures = [pool.submit(resolve, project) for project in projects]
for future in concurrent.futures.as_completed(futures):
results.append(future.result())
save(report_file, {"recordedAt": now(), "complete": False,
"manifestCommit": locked("capyloon-manifests")["commit"],
"projects": results})
if len(results) % 25 == 0:
print(f"Resolved {len(results)}/{len(projects)} references", flush=True)
results.sort(key=lambda x: x["path"])
errors = [x for x in results if "error" in x]
save(report_file, {"recordedAt": now(), "complete": not errors,
"manifestCommit": locked("capyloon-manifests")["commit"],
"target": args.target, "referenceOnly": True, "projects": results})
if errors:
for item in errors:
print(f"Unavailable: {item['path']}: {item['error']}")
return 1
root = ET.Element("manifest")
revisions = {x["path"]: x["commit"] for x in results}
def expand(name):
for element in ET.parse(folder / name).getroot():
if element.tag == "include":
expand(element.attrib["name"])
else:
element = copy.deepcopy(element)
if element.tag == "project":
element.set("revision", revisions[element.get("path", element.attrib["name"])])
root.append(element)
expand(args.target + ".xml")
ET.indent(root)
ET.ElementTree(root).write(manifest_file, encoding="utf-8", xml_declaration=True)
print(f"Pinned reference manifest: {manifest_file}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Verify or boot the locally qualified Sargo development image in RAM."""
import argparse
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
from tundra import ROOT, digest
def qualified_build():
pointer=json.loads((ROOT/'out/sargo-native-qualified.json').read_text())
folder=(ROOT/pointer['directory']).resolve()
folder.relative_to((ROOT/'out').resolve())
report=folder/'qualification.json'
if digest(report)!=pointer['qualificationSha256']:
raise ValueError('Qualification report changed')
qualification=json.loads(report.read_text())
if not qualification['passed']:
raise ValueError('Expected a qualified native boot')
build=json.loads((folder/'build.json').read_text())
if build.get('interactiveDiagnostics'):
raise ValueError('Interactive diagnostic images must be launched explicitly with test-rescue-boot.py')
if qualification['imageSha256']!=build['imageSha256']:
raise ValueError('Image differs from the qualified build')
for name,expected in qualification['evidenceSha256'].items():
evidence=(folder/name).resolve();evidence.relative_to(folder)
if digest(evidence)!=expected:
raise ValueError('Qualification evidence changed: '+name)
subprocess.run([sys.executable,str(ROOT/'tools/test-rescue-boot.py'),'--build',str(folder)],check=True)
return folder
def main():
parser=argparse.ArgumentParser(description=__doc__)
mode=parser.add_mutually_exclusive_group()
mode.add_argument('--boot',action='store_true',help='Boot the connected inventoried Sargo, already in Fastboot')
mode.add_argument('--collect',action='store_true',help='Collect the currently running RAM trial')
parser.add_argument('--reboot-from-droidian',action='store_true',help='Use the existing authenticated SSH master to request Fastboot first')
args=parser.parse_args()
if args.reboot_from_droidian and not args.boot:
parser.error('--reboot-from-droidian requires --boot')
folder=qualified_build()
print('Vulpes / Tundra Sargo — Gaia 2.7, Gecko 156.0.1',flush=True)
build=json.loads((folder/'build.json').read_text())
if build.get('persistentState'):
installed = folder/'installation-validation.json'
if installed.is_file() and json.loads(installed.read_text()).get('passed'):
print('Cette image est installée dans boot_a : un démarrage normal lance Vulpes.',flush=True)
print('Cet outil vérifie le candidat ou lance un essai RAM avec --boot ; il ne flashe pas.',flush=True)
else:
print('Essai du noyau par Fastboot ; données Tundra persistantes. Aucun flash.',flush=True)
if build['watchdogSeconds'] is None and not installed.is_file():
print('Pas de redémarrage automatique. Sans installation, un reboot revient au système conservé.',flush=True)
else:
print('Essai en RAM de 10 minutes, puis retour à Droidian. Aucun flash.',flush=True)
if args.reboot_from_droidian:
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();boot.reboot_from_droidian(plan)
if args.boot:
subprocess.run([sys.executable,str(ROOT/'tools/test-rescue-boot.py'),'--build',str(folder),'--boot'],check=True)
elif args.collect:
subprocess.run([sys.executable,str(ROOT/'tools/rescue-ssh.py'),'--build',str(folder),'--collect'],check=True)
else:
print('Vérification locale terminée. Utiliser --boot pour démarrer depuis Fastboot.')
if __name__=='__main__':
try:main()
except (OSError,ValueError,RuntimeError,subprocess.CalledProcessError) as error:
sys.exit(str(error))

103
tundra/tools/run-vm.py Normal file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Boot Tundra's graphical guest; no NIC, no host shares, disposable disk overlay."""
import argparse
import fcntl
import json
import hashlib
import os
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import time
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'out/graphical-vm'
LOG=ROOT/'logs'
def qmp(path, command, arguments=None):
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(5)
sock.connect(str(path))
stream=sock.makefile('rwb',buffering=0)
json.loads(stream.readline())
stream.write(b'{"execute":"qmp_capabilities"}\n')
while 'return' not in json.loads(stream.readline()): pass
stream.write((json.dumps({'execute':command,'arguments':arguments or {}})+'\n').encode())
while True:
result=json.loads(stream.readline())
if 'error' in result: raise RuntimeError(result)
if 'return' in result: return result['return']
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--test',action='store_true',help='Headless real DRM display, guest tests and screenshot')
parser.add_argument('--usb-test',action='store_true',help='Also test read-only FunctionFS on dummy_hcd inside the VM')
parser.add_argument('--show',action='store_true',help='Show the QEMU window while running the automated test')
parser.add_argument('--memory',type=int,default=3072)
args=parser.parse_args()
for name in ('root.ext4','vmlinuz','initramfs.cpio.gz','build.json'):
if not (OUT/name).is_file(): raise SystemExit('Build first: python3 tundra/tools/build-vm.py')
LOG.mkdir(exist_ok=True)
label='graphical-vm' if args.test else 'graphical-vm-session'
with (LOG/'graphical-vm.lock').open('w') as guard, tempfile.TemporaryDirectory(prefix='tundra-qmp-') as temp:
fcntl.flock(guard,fcntl.LOCK_EX|fcntl.LOCK_NB)
qmp_path=Path(temp)/'qmp.sock'
serial=LOG/(label+'.log')
serial.write_text('')
command=['qemu-system-x86_64','-enable-kvm','-cpu','host','-smp','2','-m',str(args.memory),
'-kernel',str(OUT/'vmlinuz'),'-initrd',str(OUT/'initramfs.cpio.gz'),
'-append','console=ttyS0 rdinit=/init panic=-1'+(' tundra.test=1' if args.test else '')+(' tundra.usb=1' if args.usb_test else ''),
'-drive',f'file={OUT}/root.ext4,format=raw,if=virtio,snapshot=on',
'-net','none','-device','virtio-vga,xres=600,yres=960','-vga','none',
'-device','virtio-keyboard-pci','-device','virtio-tablet-pci',
'-display','none' if args.test and not args.show else 'gtk,zoom-to-fit=off',
'-serial',f'file:{serial}','-qmp',f'unix:{qmp_path},server=on,wait=off','-no-reboot']
captured=False
clicked=False
print(f'Tundra VM — {args.memory} MiB RAM, disque de session jetable, réseau local uniquement.',flush=True)
proc=subprocess.Popen(command)
deadline=time.monotonic()+240 if args.test else float('inf')
try:
while proc.poll() is None:
log=serial.read_text(errors='replace')
if 'TUNDRA_POINTER_HOME' in log and not clicked:
qmp(qmp_path,'screendump',{'filename':str(LOG/(label+'-clock.ppm'))})
qmp(qmp_path,'input-send-event',{'events':[
{'type':'abs','data':{'axis':'x','value':16384}},
{'type':'abs','data':{'axis':'y','value':32085}}]})
time.sleep(.5)
qmp(qmp_path,'input-send-event',{'events':[{'type':'btn','data':{'button':'left','down':True}}]})
time.sleep(.1)
qmp(qmp_path,'input-send-event',{'events':[{'type':'btn','data':{'button':'left','down':False}}]})
clicked=True
if 'TUNDRA_GUI_READY' in log and not captured:
qmp(qmp_path,'screendump',{'filename':str(LOG/(label+'.ppm'))})
captured=True
print('Gaia guest checks passed; screenshot captured.',flush=True)
if time.monotonic()>deadline:
try: qmp(qmp_path,'screendump',{'filename':str(LOG/(label+'-failed.ppm'))})
except Exception: pass
raise TimeoutError(f'Guest did not finish; see {serial}')
time.sleep(1)
finally:
if proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=10)
except subprocess.TimeoutExpired: proc.kill(); proc.wait()
text=serial.read_text(errors='replace')
reports=[line.removeprefix('TUNDRA_GUI_TEST ') for line in text.splitlines() if line.startswith('TUNDRA_GUI_TEST ')]
decoded=[json.loads(item) for item in reports]
report=next((item for item in decoded if 'uid' in item), decoded[-1] if decoded else {'passed':False,'error':'No guest test result'})
usb_reports=[json.loads(line.removeprefix('TUNDRA_USB_TEST ')) for line in text.splitlines() if line.startswith('TUNDRA_USB_TEST ')]
if args.usb_test:
report['usb']=usb_reports[-1] if usb_reports else {'passed':False,'error':'No USB result'}
report['passed']=report['passed'] and report['usb']['passed']
report['buildManifestSha256']=hashlib.sha256((OUT/'build.json').read_bytes()).hexdigest()
report.update(screenshot=captured, qemuExit=proc.returncode, scope='x86_64 VM only; no Pixel/HAL test')
(LOG/(label+'-test.json')).write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
if args.test and not (report['passed'] and captured and proc.returncode==0): raise SystemExit(1)
if __name__=='__main__': main()

View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage a private runtime reference without importing the phone's configuration."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import posixpath
import tarfile
def destination(name):
path = PurePosixPath(name)
if path.is_absolute() or '..' in path.parts or not path.parts or path.parts[0] not in ('usr', 'bin', 'sbin', 'lib', 'lib64'):
raise ValueError('Invalid archive path: '+name)
return str(path)
def link_target(name, target):
absolute = posixpath.normpath(target if target.startswith('/') else
posixpath.join('/', posixpath.dirname(name), target))
return posixpath.relpath(absolute, '/'+posixpath.dirname(name))
def merged_path(name):
return 'usr/'+name if name.split('/')[0] in ('bin', 'sbin', 'lib', 'lib64') else name
def stage(source, root):
manifest = json.loads((source/'manifest.json').read_text())
with (source/'runtime.tar').open('rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
if digest != manifest['archiveSha256']:
raise ValueError('Runtime archive checksum mismatch')
root.mkdir(parents=True, exist_ok=False)
links = []
with tarfile.open(source/'runtime.tar') as archive:
for item in archive:
name = destination(item.name)
record = manifest['files']['/'+name]
output_name = merged_path(name)
target = root/output_name
target.parent.mkdir(parents=True, exist_ok=True)
if item.issym():
if record['symlink'] != item.linkname:
raise ValueError('Symlink metadata mismatch: '+name)
links.append((output_name, item.linkname))
elif item.isfile():
with archive.extractfile(item) as stream, target.open('xb') as output:
hasher = hashlib.sha256()
while data := stream.read(1024*1024):
hasher.update(data)
output.write(data)
if hasher.hexdigest() != record['sha256']:
raise ValueError('File checksum mismatch: '+name)
target.chmod(item.mode & 0o1777)
else:
raise ValueError('Unsupported archive member: '+name)
# Extract regular files first so an archive symlink can never redirect a write.
for name, target in links:
path = root/name
if path.exists() or path.is_symlink() or any(p.is_symlink() for p in path.parents):
raise ValueError('Archive symlink conflicts with another path: '+name)
path.symlink_to(link_target(name, target))
for name in ('usr/bin', 'usr/sbin', 'usr/lib', 'etc/fonts', 'dev', 'proc', 'sys', 'run', 'tmp', 'var/tmp', 'var/log',
'opt/vulpes', 'home/vulpes', 'root'):
(root/name).mkdir(parents=True, exist_ok=True)
for name in ('bin', 'sbin', 'lib'):
if not (root/name).exists():
(root/name).symlink_to('usr/'+name)
if not (root/'usr/bin/sh').exists() and not (root/'usr/bin/sh').is_symlink():
(root/'usr/bin/sh').symlink_to('bash')
config = {
'passwd': 'root:x:0:0:root:/root:/bin/bash\nvulpes:x:1000:1000:Vulpes:/home/vulpes:/bin/bash\n',
'group': 'root:x:0:\nvulpes:x:1000:\n',
'shadow': 'root:!:20000:0:99999:7:::\nvulpes:!:20000:0:99999:7:::\n',
'nsswitch.conf': 'passwd: files\ngroup: files\nshadow: files\nhosts: files dns\n',
'hosts': '127.0.0.1 localhost\n::1 localhost\n',
'ld.so.conf': '/usr/lib/aarch64-linux-gnu\n/usr/lib\n',
'fonts/fonts.conf': '<?xml version="1.0"?><!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"><fontconfig><dir>/usr/share/fonts</dir><cachedir prefix="xdg">fontconfig</cachedir><alias><family>sans-serif</family><prefer><family>Nimbus Sans</family></prefer></alias></fontconfig>\n',
'os-release': 'NAME="Tundra runtime reference"\nID=tundra\nPRETTY_NAME="Tundra ARM64 reference (not bootable)"\n',
}
for name, value in config.items():
(root/'etc'/name).write_text(value)
(root/'etc/shadow').chmod(0o600)
for name in ('tmp', 'var/tmp'):
(root/name).chmod(0o1777)
report = {'scope': 'Package runtime reference only; no init, HAL mounts or standalone boot',
'archiveSha256': digest, 'packages': len(manifest['packages']),
'packageFiles': len(manifest['files']), 'sourceMissingFiles': manifest['missing'],
'hostConfigurationCopied': False, 'setuidPrograms': False}
(root.parent/(root.name+'-stage.json')).write_text(json.dumps(report, indent=2)+'\n')
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True)
parser.add_argument('--root', type=Path, required=True)
args = parser.parse_args()
if os.geteuid() == 0:
parser.error('Stage as an unprivileged user')
print(json.dumps(stage(args.source, args.root), indent=2))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Play a quiet test tone and measure microphone PCM; retain no recording."""
import json
import math
import struct
import subprocess
import time
def main():
report = {'recordedAtUnix': time.time(), 'recordingRetained': False,
'scope': 'Physical PulseAudio endpoints, not in-call audio'}
# Use a per-stream volume; leave the user's sink volume and mute unchanged.
tone = b''.join(struct.pack('<h', int(16000*math.sin(2*math.pi*440*i/48000)))
for i in range(48000))
played = subprocess.run(['paplay', '--raw', '--rate=48000', '--channels=1',
'--format=s16le', '--volume=6554',
'--device=sink.primary_output'], input=tone,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=10)
report['playback'] = {'exitCode': played.returncode, 'device': 'sink.primary_output',
'audibilityConfirmed': False}
recording = subprocess.Popen(['parec', '--raw', '--rate=48000', '--channels=1',
'--format=s16le', '--device=source.droid'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
try: data, error = recording.communicate(timeout=3)
except subprocess.TimeoutExpired:
recording.terminate()
data, error = recording.communicate(timeout=3)
finally:
if recording.poll() is None:
recording.kill()
recording.wait()
samples = struct.unpack('<'+'h'*(len(data)//2), data[:len(data)//2*2])
report['microphone'] = {'device': 'source.droid', 'sampleCount': len(samples),
'rms': round(math.sqrt(sum(x*x for x in samples)/len(samples)), 2) if samples else None,
'peak': max(map(abs, samples), default=None),
'nonSilent': any(samples)}
print(json.dumps(report, indent=2))
if __name__ == '__main__': main()

View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Smoke-test Droidian's Qt AAL camera backend without saving an image.
The old gst-droid probe is not a valid Sargo test: it loads the obsolete
Stagefright codec library before it reaches the Android camera provider. The
shipping camera application uses QtMultimedia's AAL plugin instead. This
check reports configuration and process startup only. A process can remain
alive while the camera backend is blocked; that does not validate the sensor.
"""
import configparser
import json
import os
from pathlib import Path
import subprocess
import time
def main():
config_path = Path('/usr/lib/droidian/device/droidian-camera.conf')
config = configparser.ConfigParser()
config.read(config_path)
backend = config.get('General', 'backend', fallback='').strip().lower()
env = os.environ.copy()
env.setdefault('QT_QPA_PLATFORM', 'offscreen')
env.setdefault('XDG_RUNTIME_DIR', '/run/user/32011')
env.setdefault('HOME', '/home/droidian')
started = time.monotonic()
try:
result = subprocess.run(
['/usr/bin/droidian-camera'], env=env, capture_output=True,
text=True, timeout=8)
exit_code = result.returncode
except subprocess.TimeoutExpired as error:
# Timeout is deliberately not treated as successful initialization.
result = error
exit_code = 124
stderr = getattr(result, 'stderr', '') or ''
stdout = getattr(result, 'stdout', '') or ''
if isinstance(stderr, bytes):
stderr = stderr.decode(errors='replace')
if isinstance(stdout, bytes):
stdout = stdout.decode(errors='replace')
print(json.dumps({
'recordedAtUnix': time.time(),
'backendConfig': str(config_path),
'backend': backend,
'applicationInstalled': Path('/usr/bin/droidian-camera').exists(),
'processStayedAlive': exit_code == 124,
'initializationVerified': False,
'previewVerified': False,
'captureTested': False,
'recordingRetained': False,
'stagefrightWarning': 'libstagefright_ccodec.so' in stderr,
'exitCode': exit_code,
'seconds': round(time.monotonic() - started, 2),
'stderrTail': stderr[-1000:],
'stdoutTail': stdout[-1000:],
}, indent=2))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Count HAL preview frames through gst-droid; never save pictures."""
import ctypes as C
import ctypes.util
import json
import time
def main():
gst = C.CDLL(ctypes.util.find_library('gstreamer-1.0'))
obj = C.CDLL(ctypes.util.find_library('gobject-2.0'))
glib = C.CDLL(ctypes.util.find_library('glib-2.0'))
def api(lib, name, returns, args):
fun = getattr(lib, name); fun.restype = returns; fun.argtypes = args; return fun
ptr = C.c_void_p
api(gst, 'gst_init', None, [ptr,ptr])(None,None)
parse = api(gst,'gst_parse_launch',ptr,[C.c_char_p,ptr])
get = api(gst,'gst_bin_get_by_name',ptr,[ptr,C.c_char_p])
state = api(gst,'gst_element_set_state',C.c_int,[ptr,C.c_int])
unref = api(gst,'gst_object_unref',None,[ptr])
bus_get = api(gst,'gst_element_get_bus',ptr,[ptr])
pop = api(gst,'gst_bus_timed_pop_filtered',ptr,[ptr,C.c_uint64,C.c_uint])
frames = []
callback_type = C.CFUNCTYPE(None,ptr,ptr,ptr,ptr)
@callback_type
def handoff(sink, buffer, pad, data):
frames.append(time.monotonic())
connect = api(obj,'g_signal_connect_data',C.c_ulong,[ptr,C.c_char_p,callback_type,ptr,ptr,C.c_int])
pipeline = parse(b'droidcamsrc name=camera ! fakesink name=sink signal-handoffs=true sync=false',None)
if not pipeline: raise RuntimeError('Camera pipeline unavailable')
sink = get(pipeline,b'sink'); bus = bus_get(pipeline)
if not sink: raise RuntimeError('Camera sink unavailable')
connect(sink,b'handoff',handoff,None,None,0)
report = {'recordedAtUnix':time.time(),'recordingRetained':False,'backend':'gst-droid','error':None}
started = time.monotonic()
try:
report['stateChangeResult'] = state(pipeline,4)
deadline = started+12
while time.monotonic()<deadline and len(frames)<20:
message = pop(bus,100000000,2) # GST_MESSAGE_ERROR
if message:
error = ptr(); debug = C.c_char_p()
api(gst,'gst_message_parse_error',None,[ptr,ptr,ptr])(message,C.byref(error),C.byref(debug))
class GError(C.Structure): _fields_ = [('domain',C.c_uint),('code',C.c_int),('message',C.c_char_p)]
if error:
e=C.cast(error,C.POINTER(GError)).contents
report['error']={'code':e.code,'message':e.message.decode(errors='replace')}
api(glib,'g_error_free',None,[ptr])(error)
api(glib,'g_free',None,[ptr])(C.cast(debug,ptr))
api(gst,'gst_message_unref',None,[ptr])(message)
break
report['previewFrames'] = len(frames)
report['seconds'] = round(time.monotonic()-started,2)
report['passed'] = len(frames)>=20 and not report['error']
finally:
state(pipeline,1)
unref(sink); unref(bus); unref(pipeline)
print(json.dumps(report,indent=2),flush=True)
if __name__ == '__main__': main()

View file

@ -0,0 +1,42 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Temporarily boot the private rescue image on the inventoried Sargo. Never flash."""
import argparse
import importlib.util
import json
from pathlib import Path
import subprocess
import time
from tundra import ROOT,digest,save,now
def verify_sources(folder, build):
base=folder/'sources' if build.get('sourceSnapshot') else ROOT.parent
for name,sha in {**{'tundra/'+n:h for n,h in build['sources'].items()},
**build.get('sharedUiHashes',{})}.items():
path=(base/name).resolve()
path.relative_to(base.resolve())
if digest(path)!=sha:raise ValueError('Build source changed: '+name)
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()
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']
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)
print('Private image, source and backup provenance checked.',flush=True)
if not args.boot:return
# The inventoried HAL can take about three minutes to shut down after SSH
# closes. Allow that transition; identity checks still precede any boot.
serial=boot.find_fastboot(plan['serialSha256'],300);values=boot.check_device(serial,plan['serialSha256'])
report={'recordedAt':now(),'imageSha256':digest(image),'bootloader':values,'operation':'fastboot boot','flashCommandsSent':False}
result=boot.fastboot(serial,'boot',str(image));report['output']=result.stdout+result.stderr
save(folder/'boot-report.json',report)
deadline=build['watchdogSeconds']
message=f'Automatic return after {deadline} seconds.' if deadline else 'Persistent development boot; no automatic reboot.'
print(f'RAM boot sent. USB SSH: 10.15.19.82:2222. {message}',flush=True)
if __name__=='__main__':main()

View file

@ -0,0 +1,217 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Check or temporarily boot the qualified Sargo USB diagnostic. Never flash."""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import time
from boot_backup import verify
from tundra import ROOT, digest, now, save
from usb_protocol import exchange, device_layout
def checked_plan():
path = ROOT/'logs/device/boot-backup-qualification.json'
validation = json.loads((ROOT/'VALIDATION.json').read_text())
if validation.get('bootBackup', {}).get('reportSha256') != digest(path):
raise ValueError('Backup qualification report changed')
plan = json.loads(path.read_text())
backup = Path(plan['backupDirectory'])
verify(backup)
if digest(backup/'manifest.json') != plan['backupManifestSha256']:
raise ValueError('Backup manifest changed; requalify')
image = ROOT/plan['diagnosticImage']
if digest(image) != plan['diagnosticImageSha256'] or digest(image.parent/'build.json') != plan['diagnosticBuildSha256']:
raise ValueError('Diagnostic image/build changed; requalify')
build = json.loads((image.parent/'build.json').read_text())
if build['sourceSha256'] != digest(ROOT/'boot/usb-init.c') or build['sharedInitSha256'] != digest(ROOT/'boot/init.c'):
raise ValueError('Diagnostic sources changed; rebuild and requalify')
if not build['roundtripPassed'] or build.get('rawBootSlot') != 'a' or build['gaiaIncluded']:
raise ValueError('Expected the roundtrip-checked USB-only slot A diagnostic')
return plan, image
def fastboot(serial, *args):
# Whitelist the complete command shape: no flash, erase, slot or unlock path.
if not (len(args) == 2 and args[0] in ('getvar', 'boot')):
raise ValueError('Only getvar and temporary boot are implemented')
return subprocess.run(['fastboot','-s',serial,*args], capture_output=True,
text=True, check=True, timeout=45)
def getvar(serial, name):
result = fastboot(serial, 'getvar', name)
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 RuntimeError('Fastboot did not return '+name)
def check_device(serial, expected_hash):
if hashlib.sha256(serial.encode()).hexdigest() != expected_hash:
raise ValueError('Fastboot device differs from the inventoried phone')
values = {name:getvar(serial,name) for name in ('product','unlocked','current-slot','partition-size:boot_a')}
if values['product'] != 'sargo' or values['unlocked'] != 'yes' or values['current-slot'] != 'a':
raise ValueError('Expected unlocked Sargo on slot A; device left untouched')
if int(values['partition-size:boot_a'],16) != 67108864:
raise ValueError('Boot partition size differs from inventory')
return values
def reboot_from_droidian(plan):
if not sys.stdin.isatty():
raise RuntimeError('Run in your terminal to enter the phone sudo password')
spec = importlib.util.spec_from_file_location('collector', ROOT/'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
ssh = collector.ssh_base(ROOT/'logs/device/ssh-control', '10.15.19.82', 'droidian')
serial = subprocess.check_output(ssh+[shlex.join(['getprop','ro.boot.serialno'])],text=True,timeout=10).strip()
if hashlib.sha256(serial.encode()).hexdigest() != plan['serialSha256']:
raise ValueError('SSH identity changed; refusing reboot')
print('Rebooting the inventoried phone into Fastboot. Enter its sudo password if requested.',flush=True)
result = subprocess.run(ssh[:-1]+['-t',ssh[-1],
shlex.join(['sudo','/usr/bin/systemctl','reboot','--reboot-argument=bootloader'])])
# The SSH transport can disappear as shutdown completes. Fastboot must still identify the phone.
if result.returncode not in (0,255):
raise RuntimeError('Phone reboot request failed')
def find_fastboot(expected_hash, timeout):
deadline = time.monotonic()+timeout
while True:
output = subprocess.check_output(['fastboot','devices'],text=True,timeout=5)
for line in output.splitlines():
fields = line.split()
if len(fields) >= 2 and fields[1] == 'fastboot' and hashlib.sha256(fields[0].encode()).hexdigest() == expected_hash:
return fields[0]
if time.monotonic() >= deadline:
raise RuntimeError('Inventoried phone not present in Fastboot; no boot command sent')
time.sleep(1)
def find_diagnostic():
matches = []
for p in Path('/sys/bus/usb/devices').iterdir():
try:
if [(p/key).read_text().strip() for key in ('idVendor','idProduct','serial')] == ['1d6b','0104','TUNDRA-DIAG']:
matches.append(Path('/dev/bus/usb')/f'{int((p/"busnum").read_text()):03d}'/f'{int((p/"devnum").read_text()):03d}')
except (OSError,ValueError):
continue
if len(matches) > 1:
raise RuntimeError('Multiple USB diagnostics present; select a device manually')
return matches[0] if matches else None
def main():
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group()
mode.add_argument('--boot',action='store_true',help='Download and boot the diagnostic in RAM')
mode.add_argument('--collect',action='store_true',help='Resume USB checks on the already running diagnostic; no reboot')
parser.add_argument('--reboot-from-droidian',action='store_true',help='Request sudo reboot through the existing SSH master')
args = parser.parse_args()
if args.reboot_from_droidian and not args.boot:
parser.error('--reboot-from-droidian requires --boot')
os.umask(0o077)
plan,image = checked_plan()
print('Backup hashes, image and source provenance: OK',flush=True)
print('This diagnostic has no graphical interface.',flush=True)
if not args.boot and not args.collect:
print('Local checks only; no device operation performed.')
return
if args.reboot_from_droidian:
reboot_from_droidian(plan)
previous = None
values = None
if args.collect:
for candidate in sorted((ROOT/'logs/device').glob('temporary-boot-*.json'),reverse=True):
old = json.loads(candidate.read_text())
if (old.get('operation') == 'fastboot boot' and old.get('fastbootOutput')
and old.get('imageSha256') == plan['diagnosticImageSha256']
and old.get('serialSha256') == plan['serialSha256']):
previous = {'report':str(candidate.relative_to(ROOT)), 'sha256':digest(candidate)}
break
if not previous:
raise RuntimeError('No previous boot report matches this image and phone')
if not find_diagnostic():
raise RuntimeError('Diagnostic USB is no longer present; no reboot requested')
else:
serial = find_fastboot(plan['serialSha256'],60 if args.reboot_from_droidian else 0)
values = check_device(serial,plan['serialSha256'])
if find_diagnostic():
raise RuntimeError('A diagnostic is already connected; use --collect to resume its checks')
report = {'recordedAt':now(),'serialSha256':plan['serialSha256'],
'imageSha256':digest(image),'bootloader':values,'operation':'usb collect' if args.collect else 'fastboot boot',
'previousBoot':previous,
'flashCommandsSent':False,'passed':False,'gaiaIncluded':False}
path = ROOT/'logs/device'/('temporary-boot-'+now().replace(':','-')+'.json')
save(path,report)
try:
if not args.collect:
print('Booting diagnostic in RAM; waiting up to 90 seconds for its USB interface.',flush=True)
result = fastboot(serial,'boot',str(image))
report['fastbootOutput'] = result.stdout+result.stderr
deadline = time.monotonic()+90
device = None
while time.monotonic() < deadline:
device = find_diagnostic()
if device:
break
time.sleep(1)
if not device:
raise RuntimeError('Diagnostic USB not detected; hardware boot remains unvalidated')
report['usbDevice'] = str(device)
report['usbLayout'] = device_layout(device)
layout = report['usbLayout']
print(f"USB interface {layout['interface']}: OUT 0x{layout['out']:02x}, IN 0x{layout['in']:02x}",flush=True)
try:
report['usb'] = exchange(device,('status','version','mounts','invalid'))
except PermissionError:
if not sys.stdin.isatty():
raise RuntimeError('USB access denied; rerun this script with --collect in your terminal (PC sudo required)')
print('Linux USB access needs the PC sudo password. Only the diagnostic reader runs as root.',flush=True)
report['usb'] = json.loads(subprocess.check_output(['sudo',sys.executable,
str(ROOT/'tools/read-usb.py'),'--device',str(device),'--stdout','--check-invalid'],text=True))
if report['usb']['status'] != 'TUNDRA_USB_STATUS protocol=1 readOnly=true\nEND\n':
raise RuntimeError('Unexpected diagnostic status')
if '4.9-124-google-sargo' not in report['usb']['version'] or report['usb']['invalid'] != 'ERROR unsupported command\nEND\n':
raise RuntimeError('Unexpected kernel or command handling')
if any(line.startswith('/dev/') for line in report['usb']['mounts'].splitlines()):
raise RuntimeError('Unexpected block device mount')
report['passed'] = True
print('ARM64 diagnostic and read-only USB protocol validated on Sargo.',flush=True)
except Exception as error:
report['error'] = str(error)
raise
finally:
save(path,report)
if report['passed']:
validation = json.loads((ROOT/'VALIDATION.json').read_text())
validation['phase'] = 'pixel-diagnostic-boot-validated'
validation['sargoHardwareDiagnostic'] = {
'report':str(path.relative_to(ROOT)), 'reportSha256':digest(path),
'imageSha256':report['imageSha256'], 'passed':True,
'scope':'Temporary ARM64 init and USB on Sargo; no graphical system',
'gaiaIncluded':False, 'flashReady':False}
validation['rawSlotADiagnostic']['hardwareTested'] = True
save(ROOT/'VALIDATION.json',validation)
print('Report:',path,flush=True)
if report['passed']:
print('Return to Fastboot using the phone buttons, then select Start to boot Droidian.',flush=True)
else:
print('You can retry --collect without rebooting while the diagnostic remains connected.',flush=True)
if __name__ == '__main__':
try:
main()
except (OSError,ValueError,RuntimeError,subprocess.SubprocessError) as error:
print('Tundra boot test:',error,file=sys.stderr)
sys.exit(1)

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Explicit, bounded outgoing tests. Read the authorized recipient from stdin."""
import argparse
import json
import re
import subprocess
import sys
import time
def call(path, interface, method, *arguments):
result = subprocess.run(['busctl', '--system', '--timeout=25', '--json=short',
'call', 'org.ofono', path, interface, method, *arguments],
capture_output=True, text=True, timeout=30)
if result.returncode:
raise RuntimeError('DBus operation failed: '+interface+'.'+method)
return json.loads(result.stdout)['data'] if result.stdout.strip() else None
def properties(path, interface):
value = call(path, interface, 'GetProperties')
return value[0] if isinstance(value, list) else value
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--dial', action='store_true')
parser.add_argument('--sms', action='store_true')
args = parser.parse_args()
if not (args.dial or args.sms): parser.error('Choose an explicit outgoing operation')
recipient = sys.stdin.readline().strip()
if not re.fullmatch(r'\+[1-9][0-9]{7,14}', recipient): parser.error('Expected an international test number on stdin')
report = {'recordedAtUnix': time.time(), 'call': None, 'sms': None,
'recipientRecorded': False, 'automaticRetries': False}
if args.dial:
if call('/ril_0', 'org.ofono.VoiceCallManager', 'GetCalls')[0]:
raise RuntimeError('An existing call prevents the diagnostic')
path = None
states = []
try:
path = call('/ril_0', 'org.ofono.VoiceCallManager', 'Dial', 'ss', recipient, 'default')[0]
deadline = time.monotonic()+12
while time.monotonic() < deadline:
try:
state = properties(path, 'org.ofono.VoiceCall')['State']['data']
except RuntimeError:
states.append('removed')
break
if not states or states[-1] != state: states.append(state)
if state == 'disconnected': break
time.sleep(.5)
report['call'] = {'requested': True, 'states': states, 'conversationAudioVerified': False}
except RuntimeError as error:
report['call'] = {'error': str(error), 'states': states}
finally:
if path:
try:
call(path, 'org.ofono.VoiceCall', 'Hangup')
report['call']['hangupRequested'] = True
except RuntimeError:
report['call']['hangupRequested'] = False
if args.sms:
try:
path = call('/ril_0', 'org.ofono.MessageManager', 'SendMessage', 'ss', recipient,
'Test Vulpes OS / Tundra : SMS de verification depuis le Pixel 3a.')[0]
states = []
deadline = time.monotonic()+30
while time.monotonic() < deadline:
try: state = properties(path, 'org.ofono.Message')['State']['data']
except RuntimeError:
states.append('object-removed')
break
if not states or states[-1] != state: states.append(state)
if state in ('sent', 'failed'): break
time.sleep(.5)
report['sms'] = {'submitted': True, 'states': states, 'recipientDeliveryConfirmed': False}
except RuntimeError as error:
report['sms'] = {'error': str(error)}
print(json.dumps(report, indent=2))
if __name__ == '__main__': main()

444
tundra/tools/tundra.py Normal file
View file

@ -0,0 +1,444 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Tundra source inventory and stage-zero builds. Does not flash devices."""
import argparse
import base64
import datetime
import gzip
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import stat
import struct
import subprocess
import sys
import urllib.request
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCES = (
"capyloon-b2g", "capyloon-manifests", "capyloon-gonk-misc",
"b2gos-sargo", "ubports-sargo", "aosp-mkbootimg",
)
def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def digest(path):
with Path(path).open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def save(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(path.suffix + ".tmp")
temp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
temp.replace(path)
def run(argv, **kwargs):
return subprocess.run([str(x) for x in argv], check=True, **kwargs)
def output(argv, **kwargs):
return run(argv, capture_output=True, text=True, **kwargs).stdout.strip()
def locked(name):
lock = json.loads((ROOT / "manifests/sources.lock.json").read_text())
return next(x for x in lock["sources"] if x["name"] == name)
def checked_source(name):
entry = locked(name)
path = ROOT / "sources" / name
if output(["git", "-C", path, "rev-parse", "HEAD"]) != entry["commit"]:
raise ValueError(f"Wrong revision: {path}")
if output(["git", "-C", path, "status", "--porcelain"]):
raise ValueError(f"Modified upstream reference: {path}; preserve changes separately")
return path
def fetch(args):
for name in args.names or DEFAULT_SOURCES:
entry = locked(name)
path = ROOT / "sources" / name
if not path.exists():
run(["git", "init", "--quiet", path])
run(["git", "-C", path, "remote", "add", "origin", entry["url"]])
if output(["git", "-C", path, "status", "--porcelain"]):
raise ValueError(f"Working tree has changes: {path}")
if output(["git", "-C", path, "remote", "get-url", "origin"]) != entry["url"]:
raise ValueError(f"Unexpected source URL: {path}")
run(["git", "-C", path, "fetch", "--depth=1", "origin", entry["commit"]], timeout=600)
run(["git", "-C", path, "checkout", "--quiet", "--detach", entry["commit"]])
checked_source(name)
print(f"{name}: {entry['commit']}", flush=True)
def inventory(_args):
info = {
"recordedAt": now(), "platform": platform.platform(),
"cpuCount": os.cpu_count(), "diskFreeBytes": shutil.disk_usage(ROOT).free,
"memory": Path("/proc/meminfo").read_text(),
"kvmAccessible": os.access("/dev/kvm", os.R_OK | os.W_OK),
"tools": {x: shutil.which(x) for x in (
"git", "repo", "python3", "gcc", "clang", "ld.lld", "rustc",
"make", "ninja", "cpio", "lz4", "qemu-system-x86_64",
"qemu-system-aarch64", "adb", "fastboot", "docker",
)},
"sharedVulpes": str(ROOT.parent),
"sourceLockSha256": digest(ROOT / "manifests/sources.lock.json"),
"sourcesPresent": {},
"hardwareConnected": "not probed; no device access during preparation",
}
for source in json.loads((ROOT / "manifests/sources.lock.json").read_text())["sources"]:
path = ROOT / "sources" / source["name"]
info["sourcesPresent"][source["name"]] = path.exists()
save(ROOT / "logs/inventory.json", info)
print(json.dumps({k: v for k, v in info.items() if k != "memory"}, indent=2))
def flatten_manifest(folder, filename):
"""Expand includes without executing upstream scripts or fetching projects."""
elements = []
active = set()
def visit(name):
target = (folder / name).resolve()
if not target.is_relative_to(folder.resolve()) or target in active:
raise ValueError(f"Invalid manifest include: {name}")
active.add(target)
for item in ET.parse(target).getroot():
if item.tag == "include":
visit(item.attrib["name"])
else:
elements.append(item)
active.remove(target)
visit(filename)
remotes = {x.attrib["name"]: x.attrib["fetch"] for x in elements if x.tag == "remote"}
defaults = {}
for item in elements:
if item.tag == "default":
defaults.update(item.attrib)
projects = []
for item in elements:
if item.tag != "project":
continue
attrs = item.attrib
remote = attrs.get("remote", defaults.get("remote"))
projects.append({
"path": attrs.get("path", attrs["name"]),
"url": remotes[remote].rstrip("/") + "/" + attrs["name"],
"revision": attrs.get("revision", defaults.get("revision")),
})
return projects
def audit(_args):
folder = checked_source("capyloon-manifests")
projects = flatten_manifest(folder, "sargo.xml")
paths = [x["path"] for x in projects]
if len(paths) != len(set(paths)):
raise ValueError("Duplicate project paths in upstream manifest")
report = {
"recordedAt": now(), "manifestCommit": locked("capyloon-manifests")["commit"],
"projectCount": len(projects), "projects": projects,
"warning": "Expanded inventory only: upstream branch refs are NOT a resolved full build lock.",
"geckoReferenceMilestone": json.loads((ROOT / "manifests/engine-reference.json").read_text())["milestone"],
"vulpesEngine": json.loads((ROOT.parent / "release.json").read_text()),
"firmwareFamilies": {"capyloon": "Android 10 QQ3A.200805.001", "ubportsPublished": "Android 9 PQ3B.190801.002"},
"automaticFlashAllowed": False,
}
save(ROOT / "out/sargo/source-audit.json", report)
print(f"{len(projects)} upstream projects inventoried. No repo sync or flash performed.")
def cpio(entries):
"""Create deterministic newc, including device nodes, without root privileges."""
result = bytearray()
for ino, (name, mode, data, major, minor) in enumerate(entries + [("TRAILER!!!", 0, b"", 0, 0)], 1):
if name.startswith("/") or ".." in Path(name).parts:
raise ValueError("Unsafe archive path")
filename = name.encode() + b"\0"
fields = (ino, mode, 0, 0, 2 if stat.S_ISDIR(mode) else 1, 0,
len(data), 0, 0, major, minor, len(filename), 0)
result.extend(b"070701" + b"".join(f"{v:08x}".encode() for v in fields))
result.extend(filename)
result.extend(b"\0" * (-len(result) % 4))
result.extend(data)
result.extend(b"\0" * (-len(result) % 4))
result.extend(b"\0" * (-len(result) % 512))
return gzip.compress(bytes(result), mtime=0)
def linker():
explicit = os.environ.get("TUNDRA_LD")
if explicit:
return Path(explicit).resolve()
if shutil.which("ld.lld"):
return Path(shutil.which("ld.lld"))
for path in sorted((Path.home() / ".rustup/toolchains").glob("*/lib/rustlib/x86_64-unknown-linux-gnu/bin/rust-lld")):
alias = ROOT / "out/toolchain/ld.lld"
alias.parent.mkdir(parents=True, exist_ok=True)
if alias.is_symlink():
alias.unlink()
alias.symlink_to(path)
return alias
raise ValueError("Need ld.lld, or TUNDRA_LD pointing to an LLVM linker")
def build_init(arch):
folder = ROOT / "out" / ("qemu-x86_64" if arch == "x86_64" else "sargo-stage0")
folder.mkdir(parents=True, exist_ok=True)
common = ["-Os", "-ffreestanding", "-fno-builtin", "-fno-stack-protector",
"-fno-pie", "-fno-unwind-tables", "-fno-asynchronous-unwind-tables",
"-Wall", "-Wextra", "-Werror", "-nostdlib", "-static"]
if arch == "x86_64":
cmd = ["gcc", *common, "-no-pie", "-mgeneral-regs-only", "-Wl,--build-id=none",
"-Wl,-e,_start", ROOT / "boot/init.c", "-o", folder / "init"]
else:
ld = linker()
flavor = ["-Wl,-flavor,gnu"] if ld.name == "rust-lld" else []
cmd = ["clang", "--target=aarch64-linux-gnu", *common,
f"--ld-path={ld}", *flavor, "-Wl,--build-id=none", "-Wl,-e,_start",
ROOT / "boot/init.c", "-o", folder / "init"]
run(cmd)
binary = (folder / "init").read_bytes()
expected = 62 if arch == "x86_64" else 183
if binary[:4] != b"\x7fELF" or struct.unpack_from("<H", binary, 18)[0] != expected:
raise ValueError("Wrong ELF architecture")
entries = [(p, stat.S_IFDIR | 0o755, b"", 0, 0) for p in ("dev", "proc", "sys")]
entries += [("dev/console", stat.S_IFCHR | 0o600, b"", 5, 1),
("init", stat.S_IFREG | 0o755, binary, 0, 0)]
(folder / "initramfs.cpio.gz").write_bytes(cpio(entries))
save(folder / "build.json", {
"recordedAt": now(), "architecture": arch, "kind": "stage-zero-diagnostic",
"sourceSha256": digest(ROOT / "boot/init.c"), "compilerCommand": [str(x) for x in cmd],
"compilerVersion": output([cmd[0], "--version"]),
"initSha256": digest(folder / "init"),
"ramdiskSha256": digest(folder / "initramfs.cpio.gz"),
"geckoIncluded": False, "gaiaIncluded": False, "flashReady": False,
})
return folder
def vm(args):
folder = build_init("x86_64")
kernel = Path(args.kernel).resolve()
if not kernel.is_file():
raise ValueError(f"Missing kernel: {kernel}")
cmd = ["qemu-system-x86_64", "-machine", "q35", "-m", "512M", "-smp", "1",
"-accel", "kvm" if os.access("/dev/kvm", os.R_OK | os.W_OK) else "tcg",
"-kernel", kernel, "-initrd", folder / "initramfs.cpio.gz",
"-append", "console=ttyS0 rdinit=/init panic=-1 tundra.selftest=1",
"-nodefaults", "-no-user-config", "-display", "none", "-serial", "stdio", "-no-reboot"]
log = ROOT / "logs/qemu-stage0.log"
passed = False
timed_out = False
with log.open("w") as stream:
try:
result = subprocess.run([str(x) for x in cmd], stdout=stream, stderr=subprocess.STDOUT, timeout=60)
code = result.returncode
except subprocess.TimeoutExpired:
code = None
timed_out = True
text = log.read_text()
passed = code == 0 and "TUNDRA_STAGE0_OK" in text and "TUNDRA_STAGE0_POWEROFF" in text and "TUNDRA_STAGE0_FAILED" not in text
save(folder / "validation.json", {
"recordedAt": now(), "passed": passed, "returncode": code, "timedOut": timed_out,
"command": [str(x) for x in cmd], "kernelSha256": digest(kernel),
"ramdiskSha256": digest(folder / "initramfs.cpio.gz"),
"scope": "x86_64 diagnostic init only; no Pixel hardware, HAL, Gecko or Gaia",
"log": str(log),
})
print("\n".join(x for x in text.splitlines() if "TUNDRA" in x))
if not passed:
raise ValueError(f"VM boot failed. See {log}")
def kernel_files():
source = locked("aosp-sargo-kernel")
folder = ROOT / "downloads" / source["name"] / source["commit"]
folder.mkdir(parents=True, exist_ok=True)
url = f"{source['url']}/+/{source['commit']}/?format=JSON"
tree = json.loads(urllib.request.urlopen(url, timeout=60).read()[4:])
records = []
for name in ("Image.lz4-dtb", "dtbo.img"):
entry = next(x for x in tree["entries"] if x["name"] == name)
path = folder / name
if not path.exists():
request = f"{source['url']}/+/{source['commit']}/{name}?format=TEXT"
data = base64.b64decode(urllib.request.urlopen(request, timeout=180).read(), validate=True)
if hashlib.sha1(f"blob {len(data)}\0".encode() + data).hexdigest() != entry["id"]:
raise ValueError("Git object hash mismatch")
path.write_bytes(data)
data = path.read_bytes()
if hashlib.sha1(f"blob {len(data)}\0".encode() + data).hexdigest() != entry["id"]:
raise ValueError(f"Corrupt prebuilt: {path}")
records.append({"file": name, "gitBlob": entry["id"], "sha256": digest(path)})
save(folder / "provenance.json", {"source": source, "files": records, "recordedAt": now()})
return folder
def sargo(_args):
folder = build_init("aarch64")
kernel = kernel_files()
packager = checked_source("aosp-mkbootimg") / "mkbootimg.py"
board = json.loads((ROOT / "devices/sargo/device.json").read_text())
boot = board["diagnosticBoot"]
image = folder / "tundra-sargo-stage0-NOT-FOR-FLASHING.img"
cmd = [sys.executable, packager, "--kernel", kernel / "Image.lz4-dtb",
"--ramdisk", folder / "initramfs.cpio.gz", "--header_version", str(boot["headerVersion"]),
"--pagesize", str(boot["pageSize"]), "--base", boot["base"],
"--kernel_offset", boot["kernelOffset"], "--ramdisk_offset", boot["ramdiskOffset"],
"--tags_offset", boot["tagsOffset"], "--cmdline", boot["cmdline"], "-o", image]
run(cmd)
header = image.read_bytes()[:1648]
fields = struct.unpack_from("<10I", header, 8)
if header[:8] != b"ANDROID!" or fields[7] != boot["pageSize"] or fields[8] != boot["headerVersion"]:
raise ValueError("Unexpected Android boot header")
if image.stat().st_size > boot["partitionSizeBytes"]:
raise ValueError("Image exceeds reference boot partition")
report = {
"recordedAt": now(), "file": image.name, "sha256": digest(image),
"bytes": image.stat().st_size, "headerVersion": fields[8], "pageSize": fields[7],
"kernelSize": fields[0], "ramdiskSize": fields[2], "kernelSource": locked("aosp-sargo-kernel"),
"initSourceSha256": digest(ROOT / "boot/init.c"),
"initSha256": digest(folder / "init"),
"ramdiskSha256": digest(folder / "initramfs.cpio.gz"),
"kernelPrebuilt": True, "initBuiltFromSource": True, "structuralChecksPassed": True,
"flashReady": False, "hardwareTested": False, "geckoIncluded": False, "gaiaIncluded": False,
"blockers": ["Installed firmware/DTBO/slot layout not inventoried",
"No USB debug/recovery channel in this ramdisk",
"No hardware or ARM64 VM boot test",
"No graphics host, HAL integration, Gecko or Gaia"],
"command": [str(x) for x in cmd],
}
save(folder / "image.json", report)
(folder / "SHA256SUMS").write_text(f"{report['sha256']} {image.name}\n")
print(f"Packaged diagnostic image: {image}\nNOT ready for a device boot or flash; see image.json.")
def status(_args):
vm_report = ROOT / "out/qemu-x86_64/validation.json"
image_report = ROOT / "out/sargo-stage0/image.json"
boot_source = digest(ROOT / "boot/init.c")
vm_valid = False
if vm_report.exists():
info = json.loads(vm_report.read_text())
folder = vm_report.parent
build = json.loads((folder / "build.json").read_text())
vm_valid = bool(info["passed"] and info["ramdiskSha256"] == digest(folder / "initramfs.cpio.gz")
and build["sourceSha256"] == boot_source)
image_valid = False
if image_report.exists():
info = json.loads(image_report.read_text())
build = json.loads((image_report.parent / "build.json").read_text())
image_valid = (info["sha256"] == digest(image_report.parent / info["file"])
and info.get("initSourceSha256") == build["sourceSha256"] == boot_source
and info.get("initSha256") == digest(image_report.parent / "init")
and info.get("ramdiskSha256") == digest(image_report.parent / "initramfs.cpio.gz"))
graphical = ROOT / "logs/graphical-vm-test.json"
build_file = ROOT / "out/graphical-vm/build.json"
gui = json.loads(graphical.read_text()) if graphical.exists() else {}
gui_valid = bool(gui.get("passed") and gui.get("screenshot") and build_file.exists()
and gui.get("buildManifestSha256") == digest(build_file))
inventory_file = ROOT / "logs/device/inventory-summary.json"
inventory = json.loads(inventory_file.read_text()) if inventory_file.exists() else {}
inventory_report = ROOT / inventory.get("report", "logs/device/missing")
inventoried = bool(inventory.get("identity") == "sargo" and inventory_report.is_file()
and inventory.get("reportSha256") == digest(inventory_report))
validation = json.loads((ROOT / 'VALIDATION.json').read_text())
installed_diagnostic = validation.get('installedKernelDiagnostic', {})
installed_image = ROOT / installed_diagnostic.get('image', 'out/missing')
installed_image_valid = bool(installed_image.is_file() and
installed_diagnostic.get('imageSha256') == digest(installed_image))
trial = validation.get('droidianTrial', {})
trial_report = ROOT / trial.get('report', 'logs/device/missing')
trial_recorded = bool(trial_report.is_file() and trial.get('reportSha256') == digest(trial_report))
backup = validation.get('bootBackup', {})
backup_report = ROOT / backup.get('report', 'logs/device/missing')
backup_verified = bool(backup.get('verified') and backup_report.is_file()
and backup.get('reportSha256') == digest(backup_report))
raw_diagnostic = validation.get('rawSlotADiagnostic', {})
raw_image = ROOT / raw_diagnostic.get('image', 'out/missing')
raw_image_valid = bool(raw_image.is_file() and raw_diagnostic.get('imageSha256') == digest(raw_image))
hardware = validation.get('sargoHardwareDiagnostic', {})
hardware_report = ROOT / hardware.get('report', 'logs/device/missing')
hardware_passed = bool(raw_image_valid and hardware.get('passed')
and hardware.get('imageSha256') == raw_diagnostic.get('imageSha256')
and hardware_report.is_file() and hardware.get('reportSha256') == digest(hardware_report))
def checked_report(name):
item = validation.get(name, {})
report = ROOT/item.get('report', 'logs/device/missing')
return bool(item.get('passed') and report.is_file() and
item.get('reportSha256') == digest(report))
own_session = checked_report('ownCompositorTrial')
runtime_reference = checked_report('runtimeReference')
print(json.dumps({
"project": "Tundra", "phase": validation.get('phase'),
"diagnosticVmX86Passed": vm_valid,
"graphicalVmPassed": gui_valid,
"geckoOnTundraVm": gui.get("engine") if gui_valid else None,
"gaiaOnTundraVm": gui_valid,
"usbVirtualControllerPassed": gui_valid and gui.get("usb", {}).get("passed", False),
"sargoDiagnosticImageIntegrity": image_valid,
"installedKernelDiagnosticIntegrity": installed_image_valid,
"rawBootBackupQualificationRecorded": backup_verified,
"rawSlotADiagnosticIntegrity": raw_image_valid,
"sargoInventoried": inventoried,
"installedSystem": inventory.get("os") if inventoried else None,
"droidianTrialEngine": trial.get('engineVersion') if trial_recorded else None,
"droidianTrialGaiaPassed": trial_recorded and trial.get('gaiaPassed', False),
"droidianPhysicalDisplayValidated": trial_recorded and trial.get('physicalDisplayValidated', False),
"droidianRenderer": trial.get('renderer') if trial_recorded else None,
"droidianPhysicalTouchValidated": trial_recorded and trial.get('touchValidated', False),
"sargoHardwareTested": hardware_passed,
"sargoHardwareTestScope": hardware.get('scope') if hardware_passed else None,
"ownCompositorOnDroidianPassed": own_session,
"arm64RuntimeChrootPassed": runtime_reference,
"sargoGeckoBoot": False,
"flashReady": False,
"next": ("Integrate explicit HAL mounts and the boot sequence into the separate ARM64 runtime; see docs/ROOTFS-SARGO.md"
if own_session and runtime_reference else
"Inventory the HAL/container boot dependencies for an independent graphical rootfs"
if hardware_passed else
"Run the temporary USB diagnostic from raw slot A using tools/test-sargo-boot.py; see docs/SARGO.md"
if backup_verified and raw_image_valid else
"Back up raw boot partitions using tools/backup-sargo.py; qualify physical touch, audio, camera and suspend/resume; see docs/SARGO.md"
if trial_recorded else "Qualify restoration and adapt diagnostic to the installed Droidian v0 boot; see docs/SARGO-INVENTORY-2026-09-24.md"
if inventoried else "Read-only Pixel inventory and recovery plan before a temporary diagnostic boot; see docs/SARGO.md"),
}, indent=2))
def main():
(ROOT / "logs").mkdir(exist_ok=True)
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("inventory").set_defaults(function=inventory)
commands.add_parser("status").set_defaults(function=status)
p = commands.add_parser("fetch", help="Fetch pinned references; never execute their scripts")
p.add_argument("names", nargs="*")
p.set_defaults(function=fetch)
commands.add_parser("audit").set_defaults(function=audit)
p = commands.add_parser("test-vm", help="Boot diagnostic init in isolated, diskless QEMU")
p.add_argument("--kernel", default="/boot/vmlinuz-linux")
p.set_defaults(function=vm)
commands.add_parser("prepare-sargo", help="Package stage-zero diagnostic; NOT a flashable Vulpes release").set_defaults(function=sargo)
args = parser.parse_args()
args.function(args)
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.SubprocessError, StopIteration) as error:
sys.exit(f"Tundra: {error}")

View file

@ -0,0 +1,103 @@
# SPDX-License-Identifier: MPL-2.0
"""Bounded Linux usbfs transport for the read-only Tundra diagnostic protocol."""
import ctypes
import fcntl
from pathlib import Path
import struct
import time
class Bulk(ctypes.Structure):
_fields_=[('ep',ctypes.c_uint),('length',ctypes.c_uint),('timeout',ctypes.c_uint),('data',ctypes.c_void_p)]
def parse_endpoints(data, configuration):
"""Read the active vendor interface; FunctionFS may renumber endpoints."""
candidates = []
current = None
active = False
offset = 0
while offset < len(data):
if len(data)-offset < 2:
raise ValueError('Truncated USB descriptor')
size, kind = data[offset:offset+2]
if size < 2 or offset+size > len(data):
raise ValueError('Invalid USB descriptor length')
item = data[offset:offset+size]
if kind == 2:
if size < 9:
raise ValueError('Invalid configuration descriptor')
active = item[5] == configuration
current = None
elif kind == 4:
if size < 9:
raise ValueError('Invalid interface descriptor')
current = None
if active and item[3] == 0 and item[5:8] == bytes([255,0,0]):
current = {'interface':item[2], 'count':item[4], 'endpoints':[]}
candidates.append(current)
elif kind == 5 and current is not None:
if size < 7:
raise ValueError('Invalid endpoint descriptor')
current['endpoints'].append((item[2], item[3]&3))
offset += size
if len(candidates) != 1:
raise ValueError('Expected one active vendor diagnostic interface')
candidate = candidates[0]
endpoints = candidate['endpoints']
incoming = [ep for ep,kind in endpoints if kind == 2 and ep&0x80 and ep&0x0f]
outgoing = [ep for ep,kind in endpoints if kind == 2 and not ep&0x80 and ep&0x0f]
if candidate['count'] != 2 or len(endpoints) != 2 or len(incoming) != 1 or len(outgoing) != 1:
raise ValueError('Expected one bulk IN and one bulk OUT endpoint')
return {'interface':candidate['interface'], 'in':incoming[0], 'out':outgoing[0]}
def device_layout(device, sysfs=Path('/sys/bus/usb/devices')):
device = Path(device)
for entry in sysfs.iterdir():
try:
path = Path('/dev/bus/usb')/f'{int((entry/"busnum").read_text()):03d}'/f'{int((entry/"devnum").read_text()):03d}'
except (FileNotFoundError,NotADirectoryError):
continue
if path == device:
identity = [(entry/k).read_text().strip() for k in ('idVendor','idProduct','serial')]
if identity != ['1d6b','0104','TUNDRA-DIAG']:
raise ValueError('Selected USB device is not the Tundra diagnostic')
return parse_endpoints((entry/'descriptors').read_bytes(), int((entry/'bConfigurationValue').read_text()))
raise RuntimeError('USB diagnostic disappeared; select its current device path')
def exchange(device, commands=('status','version','mounts')):
commands = tuple(commands)
for command in commands:
if not command.isascii() or not command.isalpha() or len(command)>60:
raise ValueError('Invalid diagnostic command')
layout = device_layout(device)
libc=ctypes.CDLL(None,use_errno=True)
libc.ioctl.argtypes = [ctypes.c_int,ctypes.c_ulong,ctypes.c_void_p]
libc.ioctl.restype = ctypes.c_int
ioctl_bulk=0xc0005502 | (ctypes.sizeof(Bulk)<<16)
results={}
with Path(device).open('r+b',buffering=0) as stream:
fcntl.ioctl(stream,0x8004550f,struct.pack('I',layout['interface'])) # USBDEVFS_CLAIMINTERFACE
def bulk(ep,payload=None):
buf=ctypes.create_string_buffer(payload,len(payload)) if payload else ctypes.create_string_buffer(4096)
request=Bulk(ep,len(buf),3000,ctypes.addressof(buf))
count=libc.ioctl(stream.fileno(),ioctl_bulk,ctypes.byref(request))
if count<0:raise OSError(ctypes.get_errno(),f'USB transfer endpoint 0x{ep:02x}')
if payload is not None and count != len(payload):
raise RuntimeError('Incomplete USB command write')
return buf.raw[:count]
for command in commands:
bulk(layout['out'],(command+'\n').encode())
response=b''
deadline=time.monotonic()+6
while not response.endswith(b'\nEND\n'):
if time.monotonic() >= deadline:
raise TimeoutError('Diagnostic response did not finish')
chunk=bulk(layout['in'])
if not chunk:
raise RuntimeError('Empty USB response')
response+=chunk
if len(response)>8192:raise RuntimeError('Invalid oversized response')
results[command]=response.decode()
return results