VulpesOS/tundra/droidian/export-runtime.py
OverSu 1311bed5e9 Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
2026-10-13 00:19:45 +02:00

156 lines
7 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Export package-owned runtime files for an offline ARM64 chroot experiment.
This is a reference snapshot, not a Debian installation or a bootable rootfs.
Package scripts and host configuration are deliberately not imported.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
import tarfile
ROOT_PACKAGES = '''bash coreutils util-linux libc-bin python3 systemd systemd-sysv
udev dbus dbus-user-session libpam-systemd kbd phoc libhybris lxc lxc-android
halium-wrappers libgtk-3-0t64 libasound2t64 libdbus-glib-1-2 libxt6t64 libnss3
librsvg2-common shared-mime-info locales'''.split()
def installed_packages():
fields = ['binary:Package', 'Version', 'Architecture', 'db:Status-Abbrev',
'Depends', 'Pre-Depends', 'Provides', 'source:Package', 'source:Version']
output = subprocess.check_output(['dpkg-query', '-W', '-f',
'\t'.join('${'+f+'}' for f in fields)+'\n'], text=True)
return {p[0]: dict(zip(fields, p)) for line in output.splitlines()
if len(p := line.split('\t')) == len(fields) and p[3].startswith('ii')}
def dependency(token):
match = re.fullmatch(r'\s*([a-z0-9+.-]+)(?::([a-z0-9-]+))?'
r'\s*(?:\((<<|<=|=|>=|>>)\s*([^ )]+)\))?\s*', token)
if not match:
raise ValueError('Unsupported dependency: '+token)
return match.groups()
def closure(packages, roots):
providers = {}
for name, package in sorted(packages.items()):
base = name.split(':')[0]
providers.setdefault(base, []).append((name, package['Version']))
for token in package['Provides'].split(','):
if token.strip():
virtual, _, _, version = dependency(token)
providers.setdefault(virtual, []).append((name, version))
def resolve(expression):
for alternative in expression.split('|'):
name, arch, op, version = dependency(alternative)
candidates = sorted(providers.get(name, []),
key=lambda item: item[0].split(':')[0] != name)
for candidate, provided_version in candidates:
if arch not in (None, 'any', 'native', packages[candidate]['Architecture']):
continue
if op and (not provided_version or subprocess.run(
['dpkg', '--compare-versions', provided_version, op, version],
check=False).returncode):
continue
return candidate
raise ValueError('No installed provider: '+expression)
pending = [resolve(root) for root in roots]
result = {}
while pending:
name = pending.pop()
if name in result:
continue
package = dict(packages[name])
deps = [resolve(expr) for key in ('Pre-Depends', 'Depends')
for expr in package[key].split(',') if expr.strip()]
package['resolvedDependencies'] = deps
result[name] = package
pending.extend(deps)
return dict(sorted(result.items()))
def allowed(path):
parts = PurePosixPath(path).parts
return (len(parts) > 2 and parts[0] == '/' and '..' not in parts
and parts[1] in ('usr', 'bin', 'sbin', 'lib', 'lib64')
and parts[1:3] != ('usr', 'local'))
def export(output, packages):
output.mkdir(parents=True, exist_ok=False)
files = {}
for name in packages:
for path in subprocess.check_output(['dpkg-query', '-L', name], text=True).splitlines():
if allowed(path):
files.setdefault(path, []).append(name)
manifest = {'scope': __doc__, 'architecture': 'arm64', 'roots': ROOT_PACKAGES,
'packages': packages, 'files': {}, 'missing': []}
with tarfile.open(output/'runtime.tar', 'w') as tar:
for path, owners in sorted(files.items()):
source = Path(path)
if not source.exists() and not source.is_symlink():
manifest['missing'].append(path)
continue
if source.is_dir() and not source.is_symlink():
continue
info = tar.gettarinfo(path, arcname=path.lstrip('/'))
if not info.isfile() and not info.issym():
raise ValueError('Unexpected package file type: '+path)
info.uid = info.gid = 0
info.uname = info.gname = 'root'
info.mode &= ~0o6000 # No setuid/setgid programs in this reference snapshot.
record = {'packages': owners, 'mode': info.mode}
if info.isfile():
with source.open('rb') as stream:
record['sha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
stream.seek(0)
tar.addfile(info, stream)
else:
record['symlink'] = info.linkname
tar.addfile(info)
manifest['files'][path] = record
with (output/'runtime.tar').open('rb') as stream:
manifest['archiveSha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
(output/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
print(json.dumps({'packages': len(packages), 'files': len(manifest['files']),
'missing': manifest['missing'], 'archiveSha256': manifest['archiveSha256']}))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--with-network', action='store_true',
help='Include NetworkManager and wpa_supplicant, without saved connections')
parser.add_argument('--with-hardware', action='store_true',
help='Include oFono, PulseAudio and gst-droid, without personal configuration')
args = parser.parse_args()
if os.geteuid() == 0:
parser.error('Run as the unprivileged device user')
if subprocess.check_output(['dpkg', '--print-architecture'], text=True).strip() != 'arm64':
parser.error('Expected the ARM64 reference device')
if args.with_network:
ROOT_PACKAGES.extend(['network-manager', 'wpasupplicant'])
if args.with_hardware:
ROOT_PACKAGES.extend(['ofono', 'pulseaudio', 'pulseaudio-utils',
'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl', 'audiosystem-passthrough',
'gstreamer1.0-droid', 'bluebinder', 'libgbinder-radio',
'libgbinder-tools',
'libofonobinderpluginext', 'ofono-binder-plugin',
'ofono-configs-binder-common', 'ofono-scripts', 'ofono2mm',
'mobile-broadband-provider-info',
'pulseaudio-config-droid', 'droidian-camera',
'qt5-cameraplugin-aal', 'libhybris'])
packages = closure(installed_packages(), ROOT_PACKAGES)
export(args.output, packages)
if __name__ == '__main__':
main()