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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
54
tundra/tools/audit-kernel.py
Normal file
54
tundra/tools/audit-kernel.py
Normal 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()
|
||||
62
tundra/tools/backup-sargo.py
Normal file
62
tundra/tools/backup-sargo.py
Normal 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()
|
||||
26
tundra/tools/boot_backup.py
Normal file
26
tundra/tools/boot_backup.py
Normal 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
|
||||
31
tundra/tools/build-droidian-graphics.py
Normal file
31
tundra/tools/build-droidian-graphics.py
Normal 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()
|
||||
111
tundra/tools/build-installed-sargo.py
Normal file
111
tundra/tools/build-installed-sargo.py
Normal 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()
|
||||
239
tundra/tools/build-rescue-boot.py
Normal file
239
tundra/tools/build-rescue-boot.py
Normal 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()
|
||||
85
tundra/tools/build-runtime-image.py
Normal file
85
tundra/tools/build-runtime-image.py
Normal 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()
|
||||
56
tundra/tools/build-state-image.py
Normal file
56
tundra/tools/build-state-image.py
Normal 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()
|
||||
129
tundra/tools/build-system-image.py
Normal file
129
tundra/tools/build-system-image.py
Normal 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
44
tundra/tools/build-usb.py
Normal 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
254
tundra/tools/build-vm.py
Normal 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()
|
||||
100
tundra/tools/collect-device-ssh.py
Normal file
100
tundra/tools/collect-device-ssh.py
Normal 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())
|
||||
91
tundra/tools/collect-device.py
Normal file
91
tundra/tools/collect-device.py
Normal 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())
|
||||
68
tundra/tools/deploy-droidian.py
Normal file
68
tundra/tools/deploy-droidian.py
Normal 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()
|
||||
129
tundra/tools/droidian-trial.py
Normal file
129
tundra/tools/droidian-trial.py
Normal 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()
|
||||
128
tundra/tools/finalize-sargo-slot.py
Normal file
128
tundra/tools/finalize-sargo-slot.py
Normal 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()
|
||||
93
tundra/tools/inspect-hardware.py
Normal file
93
tundra/tools/inspect-hardware.py
Normal 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))
|
||||
70
tundra/tools/inspect-native.py
Normal file
70
tundra/tools/inspect-native.py
Normal 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)
|
||||
94
tundra/tools/install-sargo.py
Normal file
94
tundra/tools/install-sargo.py
Normal 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()
|
||||
14
tundra/tools/pack-rootfs.py
Normal file
14
tundra/tools/pack-rootfs.py
Normal 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)
|
||||
38
tundra/tools/persistent_state.py
Normal file
38
tundra/tools/persistent_state.py
Normal 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
|
||||
77
tundra/tools/prepare-droidian.py
Normal file
77
tundra/tools/prepare-droidian.py
Normal 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()
|
||||
82
tundra/tools/qualify-boot-backup.py
Normal file
82
tundra/tools/qualify-boot-backup.py
Normal 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()
|
||||
90
tundra/tools/qualify-network.py
Normal file
90
tundra/tools/qualify-network.py
Normal 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())
|
||||
76
tundra/tools/ramdisk_reference.py
Normal file
76
tundra/tools/ramdisk_reference.py
Normal 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
|
||||
32
tundra/tools/read-sargo-gpt.py
Normal file
32
tundra/tools/read-sargo-gpt.py
Normal 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
45
tundra/tools/read-usb.py
Normal 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)
|
||||
54
tundra/tools/rescue-ssh.py
Normal file
54
tundra/tools/rescue-ssh.py
Normal 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()
|
||||
100
tundra/tools/resolve-manifest.py
Normal file
100
tundra/tools/resolve-manifest.py
Normal 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())
|
||||
74
tundra/tools/run-sargo-native.py
Normal file
74
tundra/tools/run-sargo-native.py
Normal 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
103
tundra/tools/run-vm.py
Normal 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()
|
||||
108
tundra/tools/stage-runtime.py
Normal file
108
tundra/tools/stage-runtime.py
Normal 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()
|
||||
43
tundra/tools/test-audio.py
Normal file
43
tundra/tools/test-audio.py
Normal 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()
|
||||
63
tundra/tools/test-camera-aal.py
Normal file
63
tundra/tools/test-camera-aal.py
Normal 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()
|
||||
62
tundra/tools/test-camera.py
Normal file
62
tundra/tools/test-camera.py
Normal 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()
|
||||
42
tundra/tools/test-rescue-boot.py
Normal file
42
tundra/tools/test-rescue-boot.py
Normal 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()
|
||||
217
tundra/tools/test-sargo-boot.py
Normal file
217
tundra/tools/test-sargo-boot.py
Normal 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)
|
||||
83
tundra/tools/test-telephony.py
Normal file
83
tundra/tools/test-telephony.py
Normal 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
444
tundra/tools/tundra.py
Normal 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}")
|
||||
103
tundra/tools/usb_protocol.py
Normal file
103
tundra/tools/usb_protocol.py
Normal 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
|
||||
Loading…
Reference in a new issue