mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
* feat: experimental native userland that runs the Linux side without proot * fix(native): keep the visible name of link2symlink binaries * fix(ci): run the native hook build through bash * fix(native): address review findings in the hook and the ctl fallback * fix(ctl): use the broadcast fallback only when content is missing * fix(native): survive Android's seccomp filter in ld.so, libc and posix_spawn children * docs(native): how to test under Android's seccomp filter * fix(session): hide an unlistable /sys/bus/pci/devices so libpci does not abort Chromium's GPU init * fix(native): nested syscall() redirects no longer loop with libblsession's openat2 * fix(native): controllers reach Steam and the install-script evaluator no longer blocks game moves * fix(native): write the hook's NUL character literals as escapes * fix(native): proot fast path turns off and greys out while running without proot * fix(native): D-Bus logins work and Xwayland still admits root once peers read as root * fix(native): scripts on session mounts outside the rootfs run with the rootfs shell * fix(native): games on the SD card library start in their own folder * fix(native): Wine drive links proot made point at their real places * fix(native): at-calls, uid lookups and relative programs follow the session's mounts * fix(native): close-from in spawned programs covers what is open at the spawn * fix(native): service names resolve and the DNS fallback answers what was asked * build(native): the hook is built with its Go support and tracked by the preload recipe * fix(native): drive links into identity mounts stay, and relink and seccomp patch failures are logged * fix(native): seccomp-sim's filter holds every allowed call * fix(ctl): the broadcast fallback only stands in for a missing content command * fix(native): pre-2.33 glibc stat calls present owners as root too * fix(native): res_init and res_ninit call glibc, so Steam's store resolves names * fix(ci): a preload lock older than a new output rebuilds that group, and generated headers are not hashed * fix(native): a second seccomp patch run leaves other system calls alone * fix(native): the terminal and bottom desktop keep proot's environment in a native session * fix(native): any number of session mounts, and openat2 relative to a mounted folder * fix(agent): a native command's stdin is written beside its timeout * fix(ctl): native rejects --env without = and timeouts outside 0-600 s * fix(native): the resolver fallback has the network's IPv6 servers, and lookups keep the fallback's validation errors * fix(native): a spawn whose dup2 source close-from closed fails with EBADF * fix(native): __getcwd_chk checks its buffer, and seccomp-sim -a takes only call numbers * feat(native): guests see the session hostname from uname and gethostname * feat(native): the terminal and bottom desktop run without proot * fix(components): an origin APK without a newer preload output still proves the rest * fix(native): seccomp-sim -a rejects empty fields * fix(native): gethostbyname no longer answers failed lookups with Ubuntu and Debian addresses * feat(native): store installers, root helpers, Comet and Xodus run without proot * fix(native): the hook finds libraries, preloads and itself through a sandbox's mounts, and ldconfig -C makes its cache * feat(native): Flatpak sandboxes run without proot, and apply_extra runs without a runtime * fix(native): remove, glob and nftw take guest paths, and links in a mount are followed as the guest sees them * fix(native): Flatpak sandboxes get the session's mounts, their libraries' RUNPATHs, and no rootfs-only settings * fix(native): gethostbyname asks the network's DNS servers, nftw of / gives single slashes, and the library path fits mapped directories * fix(native): a native process starts in its working directory's real place * fix(native): the library path given to ld.so holds whole directories only * fix(native): Wine drive links keep their guest paths, so proot finds the library after a native session * fix(native): droiddeckctl native prepares the rootfs with the setting off * fix(native): a SysV segment stays while any process has it attached * fix(native): DNS lookups ask the network's own servers before public ones * fix(native): a program's own seccomp traps reach its own SIGSYS handler * feat(native): run native sessions under a device's own app seccomp filter for testing * fix(native): SysV attaches and collection take a lock, a failed mark fails the attach, and collection closes its directory * fix(native): shmat fails when it cannot take the segment lock * fix(native): the SysV segment lock is retried when a signal interrupts it * fix(native): process tools see the session's processes as root's * fix(native): native processes are named after their program, not ld.so * fix(native): a session's helpers keep the mode the session started in * fix(native): a keyed SysV segment stays until IPC_RMID * fix(native): a rootfs link to an absolute guest path is followed in the guest, so Wine finds its z: drive * fix(native): a sandbox whose loader can't be patched runs under proot * fix(native): creating a private SysV segment also collects stale ones * fix(native): a session stopped during startup forgets its mode * fix(xbox): a native backend is authorized by its process ancestry * fix(native): Wine's drive links are followed in the guest from any prefix * fix(native): only the startup that owns the running session publishes its mode * fix(native): a SysV segment stays until IPC_RMID even after its maker ends * fix(native): a sandbox mount nested in another mount gets its own place * fix(native): a sandbox whose mount places can't be made runs under proot
211 lines
11 KiB
Python
211 lines
11 KiB
Python
import copy
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
SPEC = importlib.util.spec_from_file_location('preload_snapshots', Path(__file__).resolve().parents[1] / 'components/preloads.py')
|
|
snapshots = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(snapshots)
|
|
|
|
|
|
def archive(files):
|
|
stream = io.BytesIO()
|
|
with tarfile.open(fileobj=stream, mode='w:gz') as tar:
|
|
for name, content in files.items():
|
|
entry = tarfile.TarInfo(name)
|
|
entry.size = len(content)
|
|
tar.addfile(entry, io.BytesIO(content))
|
|
return stream.getvalue()
|
|
|
|
|
|
class PreloadSnapshotTest(unittest.TestCase):
|
|
def setUp(self):
|
|
directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(directory.cleanup)
|
|
self.root = Path(directory.name)
|
|
for group in snapshots.INPUTS:
|
|
for name in snapshots.source_files(group):
|
|
if name.startswith('@'):
|
|
continue
|
|
target = self.root / name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes((snapshots.ROOT / name).read_bytes())
|
|
recipe = 'tools/components/build-arm64-preloads.sh'
|
|
(self.root / recipe).parent.mkdir(parents=True, exist_ok=True)
|
|
(self.root / recipe).write_text((snapshots.ROOT / recipe).read_text())
|
|
x86 = 'tools/components/recipes/x86-preloads.sh'
|
|
(self.root / x86).parent.mkdir(parents=True, exist_ok=True)
|
|
(self.root / x86).write_text((snapshots.ROOT / x86).read_text())
|
|
(self.root / '.github/workflows').mkdir(parents=True)
|
|
(self.root / '.github/workflows/build.yml').write_text((snapshots.ROOT / '.github/workflows/build.yml').read_text())
|
|
self.git('init', '-q')
|
|
self.git('config', 'core.autocrlf', 'false')
|
|
self.git('config', 'user.name', 'Test Developer')
|
|
self.git('config', 'user.email', 'test@example.test')
|
|
self.git('add', '.')
|
|
self.binaries = {name: ('verified-' + name).encode() for name in snapshots.FILES}
|
|
self.content = archive(self.binaries)
|
|
self.data = dict(version=1, origin=dict(run=123, commit='abc', artifact=456, artifact_sha256='0' * 64),
|
|
groups={g: snapshots.source_files(g, self.root) for g in snapshots.INPUTS},
|
|
arm64_recipe=snapshots.recipe(self.root), x86_recipe=snapshots.x86_recipe(self.root),
|
|
archive_sha256=snapshots.sha(self.content),
|
|
outputs={name: snapshots.sha(value) for name, value in self.binaries.items()})
|
|
(self.root / snapshots.LOCK).write_text(json.dumps(self.data))
|
|
self.git('add', '.')
|
|
self.git('commit', '-qm', 'Approved baseline')
|
|
self.base = self.git('rev-parse', 'HEAD').strip()
|
|
|
|
def git(self, *args):
|
|
return subprocess.check_output(['git', *args], cwd=self.root, text=True)
|
|
|
|
def restore(self, base=None):
|
|
with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=base or self.base, FORCE_PRELOAD_REBUILD='false'), \
|
|
patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(self.content)) as fetch, \
|
|
patch.object(snapshots, 'prove_origin') as prove:
|
|
result = snapshots.restore(self.root)
|
|
return result, fetch, prove
|
|
|
|
def test_reviewed_unchanged_binaries_reused_and_written(self):
|
|
result, fetch, prove = self.restore()
|
|
self.assertEqual(result, dict(arm64=True, x86=True))
|
|
prove.assert_not_called()
|
|
fetch.assert_called_once()
|
|
for name, value in self.binaries.items():
|
|
self.assertEqual((self.root / 'app/src/main/assets/linuxfs' / name).read_bytes(), value)
|
|
|
|
def test_changed_common_source_requires_both_rebuilds(self):
|
|
path = self.root / snapshots.COMMON[0]
|
|
path.write_text(path.read_text() + '\n// Changed source\n')
|
|
result, fetch, prove = self.restore()
|
|
self.assertEqual(result, dict(arm64=False, x86=False))
|
|
fetch.assert_not_called()
|
|
prove.assert_not_called()
|
|
|
|
def test_new_transitive_header_is_an_input(self):
|
|
source = self.root / snapshots.COMMON[0]
|
|
source.write_text(source.read_text() + '\n#include "new-header.h"\n')
|
|
header = source.with_name('new-header.h')
|
|
header.write_text('#define VALUE 1\n')
|
|
before = snapshots.source_files('arm64', self.root)
|
|
header.write_text('#define VALUE 2\n')
|
|
self.assertNotEqual(before, snapshots.source_files('arm64', self.root))
|
|
|
|
def test_changed_recipe_rebuilds_arm64_only(self):
|
|
script = self.root / 'tools/components/build-arm64-preloads.sh'
|
|
script.write_text(script.read_text().replace('-O2', '-O3'))
|
|
result, _, _ = self.restore()
|
|
self.assertEqual(result, dict(arm64=False, x86=True))
|
|
|
|
def test_shell_newline_changes_invalidate_the_recipe(self):
|
|
script = self.root / 'tools/components/build-arm64-preloads.sh'
|
|
original = snapshots.recipe(self.root)
|
|
text = script.read_text()
|
|
script.write_text(text.replace('-ldl\n', '-ldl ', 1))
|
|
self.assertNotEqual(original, snapshots.recipe(self.root))
|
|
|
|
def test_x86_container_recipe_changes_rebuild_x86_only(self):
|
|
script = self.root / 'tools/components/recipes/x86-preloads.sh'
|
|
script.write_text(script.read_text().replace('debian:bullseye', 'debian:bookworm'))
|
|
result, _, _ = self.restore()
|
|
self.assertEqual(result, dict(arm64=True, x86=False))
|
|
|
|
def test_new_manifest_requires_main_build_proof(self):
|
|
result, _, prove = self.restore('HEAD~1')
|
|
self.assertTrue(all(result.values()))
|
|
prove.assert_called_once()
|
|
|
|
def test_forged_binary_hash_cannot_prove_main_origin(self):
|
|
data = copy.deepcopy(self.data)
|
|
data['outputs']['libblsession.so'] = 'f' * 64
|
|
with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries):
|
|
with self.assertRaisesRegex(ValueError, 'original main APK'):
|
|
snapshots.prove_origin(data, self.root / 'cache', self.root)
|
|
|
|
def test_modified_input_policy_cannot_omit_producer_dependencies(self):
|
|
data = copy.deepcopy(self.data)
|
|
data['origin']['commit'] = self.base
|
|
omitted = snapshots.COMMON[0]
|
|
del data['groups']['arm64'][omitted]
|
|
with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries), \
|
|
patch.object(snapshots, 'run', return_value=b''), \
|
|
patch.object(snapshots, 'source_files', side_effect=lambda group, *args: data['groups'][group]), \
|
|
patch.object(snapshots, 'producer_sources', return_value={omitted: self.data['groups']['arm64'][omitted]}):
|
|
with self.assertRaisesRegex(ValueError, 'producing build dependency'):
|
|
snapshots.prove_origin(data, self.root / 'cache', self.root)
|
|
|
|
def test_non_main_or_unsuccessful_run_cannot_prove_origin(self):
|
|
build = dict(event='pull_request', head_branch='main', conclusion='success', head_sha='abc',
|
|
path='.github/workflows/build.yml', repository=dict(full_name=snapshots.REPO))
|
|
for field, value in [('event', 'pull_request'), ('head_branch', 'feature'), ('conclusion', 'failure')]:
|
|
candidate = dict(build, event='push')
|
|
candidate[field] = value
|
|
with self.subTest(field=field), patch.object(snapshots, 'api', return_value=candidate):
|
|
with self.assertRaisesRegex(ValueError, 'successful main'):
|
|
snapshots.artifact_bytes(self.data['origin'], self.root / 'cache')
|
|
|
|
def test_origin_apk_without_a_newer_output_reads_the_rest(self):
|
|
from zipfile import ZipFile
|
|
newest = 'libddnatv-hook.so'
|
|
apk = io.BytesIO()
|
|
with ZipFile(apk, 'w') as z:
|
|
for name, value in self.binaries.items():
|
|
if name != newest:
|
|
z.writestr('assets/linuxfs/' + name, value)
|
|
outer = io.BytesIO()
|
|
with ZipFile(outer, 'w') as z:
|
|
z.writestr('app-release.apk', apk.getvalue())
|
|
digest = snapshots.sha(outer.getvalue())
|
|
origin = dict(self.data['origin'], artifact_sha256=digest)
|
|
build = dict(event='push', head_branch='main', conclusion='success', head_sha=origin['commit'],
|
|
path='.github/workflows/build.yml', repository=dict(full_name=snapshots.REPO))
|
|
artifact = dict(name='droiddeck-apk', workflow_run=dict(id=origin['run']), digest='sha256:' + digest)
|
|
cache = self.root / 'cache'
|
|
cache.mkdir()
|
|
(cache / (digest + '.zip')).write_bytes(outer.getvalue())
|
|
with patch.object(snapshots, 'api', side_effect=[build, artifact]):
|
|
content = snapshots.artifact_bytes(origin, cache)
|
|
self.assertNotIn(newest, content)
|
|
self.assertEqual(set(content), snapshots.FILES - {newest})
|
|
|
|
def test_corrupt_archive_is_rejected(self):
|
|
with self.assertRaisesRegex(ValueError, 'checksum'):
|
|
snapshots.unpack(self.content + b'tampered', self.data)
|
|
|
|
def test_missing_duplicate_or_traversal_entries_are_rejected(self):
|
|
for files in ({}, {'../escaped': b'bad'}, {'libblsession.so': b'wrong'}):
|
|
content = archive(files)
|
|
data = dict(self.data, archive_sha256=snapshots.sha(content))
|
|
with self.subTest(names=list(files)), self.assertRaises(ValueError):
|
|
snapshots.unpack(content, data)
|
|
|
|
def test_missing_release_rebuilds_without_accepting_unverified_bytes(self):
|
|
error = snapshots.urllib.error.HTTPError('https://example.test', 404, 'missing', {}, None)
|
|
with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \
|
|
patch.object(snapshots.urllib.request, 'urlopen', side_effect=error):
|
|
self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False))
|
|
|
|
def test_transient_download_failure_rebuilds_but_integrity_errors_fail(self):
|
|
errors = [TimeoutError('network'), snapshots.http.client.IncompleteRead(b'partial'),
|
|
snapshots.urllib.error.HTTPError('https://example.test', 503, 'unavailable', {}, None)]
|
|
for error in errors:
|
|
with self.subTest(error=type(error).__name__), patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \
|
|
patch.object(snapshots.urllib.request, 'urlopen', side_effect=error):
|
|
self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False))
|
|
with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \
|
|
patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(b'corrupt')):
|
|
with self.assertRaisesRegex(ValueError, 'checksum'):
|
|
snapshots.restore(self.root)
|
|
|
|
def test_manual_rebuild_does_not_download_snapshot(self):
|
|
with patch.dict(os.environ, FORCE_PRELOAD_REBUILD='true'), \
|
|
patch.object(snapshots.urllib.request, 'urlopen') as fetch:
|
|
self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False))
|
|
fetch.assert_not_called()
|