mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 18:02:19 +02:00
fix(payload): preserve previous files across failed imports
Enable rollback before the first backup move, preserve originals during incomplete backups, and retain recovery files if restoration fails. Add offline full-importer regression coverage and register it with CTest.
This commit is contained in:
1 parent
b19bd8406f
commit
4928443f90
3 files changed
+359
-20
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user