diff --git a/scripts/update_roblox_payload.sh b/scripts/update_roblox_payload.sh index 9275395..aa728ed 100755 --- a/scripts/update_roblox_payload.sh +++ b/scripts/update_roblox_payload.sh @@ -28,6 +28,7 @@ STORE_ROOT="" TEMP_DIR="" ROLLBACK_DIR="" ROLLBACK_NEEDED=false +BACKUP_COMPLETE=false usage() { cat <<'EOF' @@ -146,34 +147,54 @@ certificate_digests() { } restore_payload() { - [[ "${ROLLBACK_NEEDED}" == true && -d "${ROLLBACK_DIR}" ]] || return 0 + [[ "${ROLLBACK_NEEDED}" == true ]] || return 0 + [[ -d "${ROLLBACK_DIR}" ]] || return 1 log WARN "restoring the previous payload" - mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")" + mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")" || return 1 - local path - for path in libroblox.so sober_apk assets; do - if [[ -e "${RBX_BIN_DIR}/${path}" ]]; then - mv -- "${RBX_BIN_DIR}/${path}" "${TEMP_DIR}/failed-${path}" + local path active_path failed_path restore_status=0 + for path in libroblox.so sober_apk assets roblox_payload.json; do + active_path="${RBX_BIN_DIR}/${path}" + failed_path="${TEMP_DIR}/failed-${path}" + if [[ "${path}" == roblox_payload.json ]]; then + active_path="${METADATA_PATH}" + failed_path="${TEMP_DIR}/failed-metadata.json" fi - if [[ -e "${ROLLBACK_DIR}/${path}" ]]; then - mv -- "${ROLLBACK_DIR}/${path}" "${RBX_BIN_DIR}/${path}" + + # Before activation, anything still active belongs to the old payload. + # Only components removed by the backup need restoring in this phase. + if [[ "${BACKUP_COMPLETE}" == false ]]; then + if [[ -e "${active_path}" || -L "${active_path}" || + ! -e "${ROLLBACK_DIR}/${path}" && ! -L "${ROLLBACK_DIR}/${path}" ]]; then + continue + fi + fi + + if [[ -e "${active_path}" || -L "${active_path}" ]]; then + # Do not restore onto an occupied destination if removing the new + # component fails: mv could overwrite a file or nest a directory. + if ! mv -- "${active_path}" "${failed_path}"; then + restore_status=1 + continue + fi + fi + if [[ -e "${ROLLBACK_DIR}/${path}" || -L "${ROLLBACK_DIR}/${path}" ]]; then + mv -- "${ROLLBACK_DIR}/${path}" "${active_path}" || restore_status=1 fi done - - if [[ -e "${METADATA_PATH}" ]]; then - mv -- "${METADATA_PATH}" "${TEMP_DIR}/failed-metadata.json" - fi - if [[ -e "${ROLLBACK_DIR}/roblox_payload.json" ]]; then - mv -- "${ROLLBACK_DIR}/roblox_payload.json" "${METADATA_PATH}" - fi + return "${restore_status}" } cleanup() { - local -r status=$? + local status=$? trap - EXIT set +e - restore_payload + if ! restore_payload; then + log ERROR "payload restoration failed; recovery files retained at ${TEMP_DIR}" + (( status != 0 )) || status=1 + exit "${status}" + fi if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then rm -rf -- "${TEMP_DIR}" fi @@ -401,17 +422,18 @@ main() { mkdir -p "${VERSIONS_DIR}" "$(dirname -- "${METADATA_PATH}")" ROLLBACK_DIR="${TEMP_DIR}/rollback" mkdir -p "${ROLLBACK_DIR}" + ROLLBACK_NEEDED=true local active_path for active_path in libroblox.so sober_apk assets; do - if [[ -e "${RBX_BIN_DIR}/${active_path}" ]]; then + if [[ -e "${RBX_BIN_DIR}/${active_path}" || -L "${RBX_BIN_DIR}/${active_path}" ]]; then mv -- "${RBX_BIN_DIR}/${active_path}" "${ROLLBACK_DIR}/${active_path}" fi done - if [[ -e "${METADATA_PATH}" ]]; then + if [[ -e "${METADATA_PATH}" || -L "${METADATA_PATH}" ]]; then mv -- "${METADATA_PATH}" "${ROLLBACK_DIR}/roblox_payload.json" fi - ROLLBACK_NEEDED=true + BACKUP_COMPLETE=true mv -- "${stage_dir}/libroblox.so" "${RBX_BIN_DIR}/libroblox.so" mv -- "${stage_dir}/sober_apk" "${RBX_BIN_DIR}/sober_apk" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e1caa78..cea1936 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,6 +22,13 @@ target_link_libraries(fleasion_probe PRIVATE Mocktail::Services libc_shim) mocktail_apply_compile_options(fleasion_probe) find_program(MOCKTAIL_TEST_PYTHON python3) find_program(MOCKTAIL_TEST_OPENSSL openssl) +if(MOCKTAIL_TEST_PYTHON) + add_test(NAME PayloadImportRollbackTest.PreservesPreviousInstallation + COMMAND ${MOCKTAIL_TEST_PYTHON} + ${CMAKE_CURRENT_SOURCE_DIR}/payload_import_rollback_test.py) + set_tests_properties(PayloadImportRollbackTest.PreservesPreviousInstallation + PROPERTIES TIMEOUT 180) +endif() if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL) add_test(NAME FleasionIntegrationTest.ProxyAndTrust COMMAND ${MOCKTAIL_TEST_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/fleasion_integration_test.py diff --git a/tests/payload_import_rollback_test.py b/tests/payload_import_rollback_test.py new file mode 100644 index 0000000..a33eec3 --- /dev/null +++ b/tests/payload_import_rollback_test.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +# Copyright 2026 Mocktail Project Authors +# Licensed under the Apache License, Version 2.0. + +"""Exercise the full legacy importer with synthetic APKs and failing renames. + +APK/ELF inspection tools are fixtures; hashing, extraction, locking, metadata, +and filesystem operations use the real tools. No installed payload is touched. +""" + +import json +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +COMPONENTS = ('libroblox.so', 'sober_apk', 'assets', 'roblox_payload.json') +OLD_FILES = { + 'libroblox.so': b'OLD-LIBRARY', + 'sober_apk/base.apk': b'OLD-APK', + 'sober_apk/split_config.x86_64.apk': b'OLD-SPLIT', + 'assets/content/data.bin': b'OLD-ASSET', + 'roblox_payload.json': b'{"old_metadata":true}\n', +} + +TOOL = r''' +import json +import os +from pathlib import Path +import subprocess +import sys + +tool = Path(sys.argv[0]).name +args = sys.argv[1:] +if tool == 'aapt': + old = Path(args[-1]).read_bytes().startswith(b'OLD-') + code, name = ('100', '1.0') if old else ('200', '2.0') + split = " split='config.x86_64'" if 'split_' in args[-1] else '' + print(f"package: name='com.roblox.client' versionCode='{code}' versionName='{name}'{split}") +elif tool == 'apksigner': + if '--print-certs' in args: + print('Signer #1 certificate SHA-256 digest: ' + 'a' * 64) +elif tool == 'file': + print(args[-1] + ': ELF 64-bit for Android') +elif tool == 'readelf': + if '-h' in args: + print(' Machine: Advanced Micro Devices X86-64') + elif '-n' in args: + old = Path(args[-1]).read_bytes().startswith(b'OLD-') + print('Build ID: ' + ('1' if old else '2') * 40) + else: + print('JNI_OnLoad') + for name in ('nativeGameGlobalInit', 'nativeUpdateAdapterInit', + 'nativeAppBridgeV2InitWithParams', 'nativeAppBridgeStartLuaAppDM', + 'nativeAppBridgeV2StartAppWithParams', + 'nativeAppBridgeV2UpdateSurfaceAppWithPlatformParams'): + print('Java_com_roblox_engine_jni_NativeGLInterface_' + name) +elif tool == 'mv': + source, destination = map(Path, args[-2:]) + phase = 'other' + component = source.name + if destination.parent.name == 'rollback': + phase = 'backup' + elif source.parent.name == 'new': + phase = 'activate' + elif destination.name.startswith('failed-'): + phase = 'discard' + component = 'roblox_payload.json' if destination.name == 'failed-metadata.json' else component + elif source.parent.name == 'rollback': + phase = 'restore' + elif source.name == 'rollback' and destination.parent.name == 'versions': + phase = 'archive' + with open(os.environ['NIGHTCAP_TEST_MOVES'], 'a') as log: + log.write(json.dumps([phase, component]) + '\n') + failures = json.loads(os.environ['NIGHTCAP_TEST_FAILURES']) + if phase == 'backup' and ['partial-backup', component] in failures: + if source.is_dir(): + destination.mkdir() + (destination / 'partial').write_bytes(b'INCOMPLETE-COPY') + else: + destination.write_bytes(b'INCOMPLETE-COPY') + print(f'injected failure: partial-backup/{component}', file=sys.stderr) + sys.exit(74) + if [phase, component] in failures: + print(f'injected failure: {phase}/{component}', file=sys.stderr) + sys.exit(74) + sys.exit(subprocess.call([os.environ['NIGHTCAP_TEST_REAL_MV'], *args])) +else: + raise AssertionError(tool) +''' + + +class PayloadImportRollbackTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='nightcap-import-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.project = self.root / 'project with spaces' + self.bin = self.root / 'bin' + self.moves = self.root / 'moves.jsonl' + for tool in ('bash', 'jq', 'unzip', 'flock', 'mv'): + self.assertIsNotNone(shutil.which(tool), f'required test tool missing: {tool}') + self.real_mv = shutil.which('mv') + (self.project / 'scripts').mkdir(parents=True) + (self.project / 'config').mkdir() + self.bin.mkdir() + for name in ('update_roblox_payload.sh', 'payload_integrity.sh'): + shutil.copy2(ROOT / 'scripts' / name, self.project / 'scripts' / name) + for name in ('aapt', 'apksigner', 'readelf', 'file', 'mv'): + path = self.bin / name + path.write_text(f'#!{sys.executable}\n' + TOOL) + path.chmod(0o755) + (self.project / 'config/roblox_signing_certificates.json').write_text( + json.dumps({'schema_version': 1, 'package': 'com.roblox.client', + 'trusted_sha256': ['a' * 64]}) + ) + self.base = self.root / 'base.apk' + self.split = self.root / 'split_config.x86_64.apk' + with zipfile.ZipFile(self.base, 'w') as archive: + archive.writestr('assets/content/data.bin', b'NEW-ASSET') + with zipfile.ZipFile(self.split, 'w') as archive: + archive.writestr('lib/x86_64/libroblox.so', b'NEW-LIBRARY') + + def active_path(self, component): + directory = 'config' if component == 'roblox_payload.json' else 'rbx_bin' + return self.project / directory / component + + def create_previous(self, components=COMPONENTS): + shutil.rmtree(self.project / 'rbx_bin', ignore_errors=True) + self.active_path('roblox_payload.json').unlink(missing_ok=True) + for relative, content in OLD_FILES.items(): + if relative.split('/')[0] not in components: + continue + path = self.active_path(relative.split('/')[0]) + if '/' in relative: + path = path / relative.split('/', 1)[1] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + def contents(self, path): + if path.is_symlink(): + return ('symlink', os.readlink(path)) + if not path.exists(): + return None + if path.is_file(): + return path.read_bytes() + return {str(p.relative_to(path)): p.read_bytes() + for p in path.rglob('*') if p.is_file()} + + def snapshot(self): + return {name: self.contents(self.active_path(name)) for name in COMPONENTS} + + def run_import(self, failures=()): + self.moves.unlink(missing_ok=True) + env = {key: value for key, value in os.environ.items() + if not key.startswith(('MOCKTAIL_', 'NIGHTCAP_TEST_'))} + env.update(PATH=str(self.bin) + os.pathsep + env['PATH'], + NIGHTCAP_TEST_REAL_MV=self.real_mv, + NIGHTCAP_TEST_FAILURES=json.dumps(failures), + NIGHTCAP_TEST_MOVES=str(self.moves)) + return subprocess.run( + ['bash', str(self.project / 'scripts/update_roblox_payload.sh'), + '--base', str(self.base), '--x86-64', str(self.split)], + env=env, capture_output=True, text=True, timeout=20, + ) + + def backups(self): + return list((self.project / 'rbx_bin').glob('.payload-update.*')) + + def assert_failed_and_restored(self, result, before, phase, component): + self.assertEqual(result.returncode, 74, result.stderr) + self.assertIn(f'injected failure: {phase}/{component}', result.stderr) + self.assertEqual(self.snapshot(), before) + self.assertEqual(self.backups(), []) + + def test_backup_failures_preserve_every_previous_component(self): + for component in COMPONENTS: + with self.subTest(component=component): + self.create_previous() + before = self.snapshot() + result = self.run_import([['backup', component]]) + self.assert_failed_and_restored(result, before, 'backup', component) + + def test_activation_failures_restore_previous_components(self): + for component in COMPONENTS: + with self.subTest(component=component): + self.create_previous() + before = self.snapshot() + result = self.run_import([['activate', component]]) + self.assert_failed_and_restored(result, before, 'activate', component) + + def test_failed_first_install_removes_activated_components(self): + for component in COMPONENTS: + with self.subTest(component=component): + self.create_previous(()) + before = self.snapshot() + result = self.run_import([['activate', component]]) + self.assert_failed_and_restored(result, before, 'activate', component) + + def test_sparse_previous_install_is_restored(self): + self.create_previous(('assets', 'roblox_payload.json')) + before = self.snapshot() + result = self.run_import([['activate', 'roblox_payload.json']]) + self.assert_failed_and_restored(result, before, 'activate', 'roblox_payload.json') + + def test_partial_backup_copy_does_not_replace_untouched_original(self): + self.create_previous() + before = self.snapshot() + result = self.run_import([['partial-backup', 'sober_apk']]) + self.assert_failed_and_restored(result, before, 'partial-backup', 'sober_apk') + + def test_restore_failure_retains_backup_and_restores_other_components(self): + for component in COMPONENTS: + with self.subTest(component=component): + self.create_previous() + before = self.snapshot() + result = self.run_import([['archive', 'rollback'], ['restore', component]]) + self.assertEqual(result.returncode, 74, result.stderr) + self.assertIn(f'injected failure: restore/{component}', result.stderr) + workspaces = self.backups() + self.assertEqual(len(workspaces), 1) + workspace = workspaces[0] + self.assertIn(str(workspace), result.stderr) + self.assertEqual(self.contents(workspace / 'rollback' / component), before[component]) + for other in COMPONENTS: + if other != component: + self.assertEqual(self.contents(self.active_path(other)), before[other]) + # A failed recovery is intentionally retained by the importer. + shutil.rmtree(workspace) + + def test_discard_failure_does_not_move_backup_into_active_directory(self): + for component in COMPONENTS: + with self.subTest(component=component): + self.create_previous() + before = self.snapshot() + result = self.run_import([['archive', 'rollback'], ['discard', component]]) + self.assertEqual(result.returncode, 74, result.stderr) + self.assertIn(f'injected failure: discard/{component}', result.stderr) + workspaces = self.backups() + self.assertEqual(len(workspaces), 1) + workspace = workspaces[0] + self.assertIn(str(workspace), result.stderr) + self.assertEqual(self.contents(workspace / 'rollback' / component), before[component]) + moves = [json.loads(line) for line in self.moves.read_text().splitlines()] + self.assertNotIn(['restore', component], moves) + for other in COMPONENTS: + if other != component: + self.assertEqual(self.contents(self.active_path(other)), before[other]) + shutil.rmtree(workspace) + + def test_successful_import_keeps_previous_archive_and_consistent_metadata(self): + self.create_previous() + before = self.snapshot() + result = self.run_import() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.active_path('libroblox.so').read_bytes(), b'NEW-LIBRARY') + self.assertEqual((self.active_path('assets') / 'content/data.bin').read_bytes(), b'NEW-ASSET') + self.assertEqual((self.active_path('sober_apk') / 'base.apk').read_bytes(), self.base.read_bytes()) + metadata = json.loads(self.active_path('roblox_payload.json').read_text()) + self.assertEqual(metadata['version_code'], 200) + self.assertEqual(metadata['elf_build_id'], '2' * 40) + self.assertEqual(metadata['compatibility_status'], 'unverified') + self.assertEqual(metadata['sha256']['libroblox'], hashlib.sha256(b'NEW-LIBRARY').hexdigest()) + self.assertEqual(metadata['sha256']['base_apk'], hashlib.sha256(self.base.read_bytes()).hexdigest()) + self.assertEqual(metadata['sha256']['x86_64_split_apk'], hashlib.sha256(self.split.read_bytes()).hexdigest()) + archive = self.project / 'rbx_bin/versions' / ('1.0-' + '1' * 40) + for component in COMPONENTS: + self.assertEqual(self.contents(archive / component), before[component]) + self.assertEqual(self.backups(), []) + + def test_successful_first_install_has_no_previous_archive(self): + result = self.run_import() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(all(self.active_path(name).exists() for name in COMPONENTS)) + self.assertEqual(list((self.project / 'rbx_bin/versions').iterdir()), []) + self.assertEqual(self.backups(), []) + + def test_successful_sparse_install_archives_unversioned_components(self): + self.create_previous(('assets', 'roblox_payload.json')) + before = self.snapshot() + result = self.run_import() + self.assertEqual(result.returncode, 0, result.stderr) + archives = list((self.project / 'rbx_bin/versions').glob('unknown-*')) + self.assertEqual(len(archives), 1) + for component in COMPONENTS: + self.assertEqual(self.contents(archives[0] / component), before[component]) + self.assertTrue(all(self.active_path(name).exists() for name in COMPONENTS)) + self.assertEqual(self.backups(), []) + + def test_successful_import_archives_dangling_asset_symlink(self): + self.create_previous() + shutil.rmtree(self.active_path('assets')) + self.active_path('assets').symlink_to('missing-assets') + result = self.run_import() + self.assertEqual(result.returncode, 0, result.stderr) + archive = self.project / 'rbx_bin/versions' / ('1.0-' + '1' * 40) + self.assertEqual(self.contents(archive / 'assets'), ('symlink', 'missing-assets')) + self.assertEqual((self.active_path('assets') / 'content/data.bin').read_bytes(), b'NEW-ASSET') + self.assertEqual(self.backups(), []) + + +if __name__ == '__main__': + unittest.main()