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:
Guilhermepycharm committed 2026-09-30 22:54:33 -03:00
1 parent b19bd8406f
commit 4928443f90
3 files changed
+359 -20

No files matched your search

+42 -20
View File
@@ -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"
+7
View File
@@ -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
+310
View File
@@ -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()