122 lines
5.8 KiB
Python
122 lines
5.8 KiB
Python
"""Restore explicitly selected missing save sections from a backup.
|
|
|
|
Never replaces a present field (including false, zero, null or an empty map).
|
|
Uses the same persistent.lock byte-range lock as the Go servers, validates the
|
|
expected file digest under that lock, and atomically replaces only persistent.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def save_lock(path):
|
|
with path.open('a+b') as lock_file:
|
|
if os.name == 'nt':
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
import msvcrt
|
|
class Overlapped(ctypes.Structure):
|
|
_fields_ = [('Internal', ctypes.c_size_t), ('InternalHigh', ctypes.c_size_t),
|
|
('Offset', wintypes.DWORD), ('OffsetHigh', wintypes.DWORD),
|
|
('hEvent', wintypes.HANDLE)]
|
|
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
kernel.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
|
|
wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(Overlapped)]
|
|
kernel.LockFileEx.restype = wintypes.BOOL
|
|
kernel.UnlockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD,
|
|
wintypes.DWORD, ctypes.POINTER(Overlapped)]
|
|
kernel.UnlockFileEx.restype = wintypes.BOOL
|
|
handle = msvcrt.get_osfhandle(lock_file.fileno())
|
|
overlapped = Overlapped()
|
|
# Win32 EXCLUSIVE_LOCK | FAIL_IMMEDIATELY, byte zero, length one.
|
|
if not kernel.LockFileEx(handle, 3, 0, 1, 0, ctypes.byref(overlapped)):
|
|
raise OSError(ctypes.get_last_error(), 'persistent is locked by another writer')
|
|
try:
|
|
yield
|
|
finally:
|
|
kernel.UnlockFileEx(handle, 0, 1, 0, ctypes.byref(overlapped))
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
try:
|
|
yield
|
|
finally:
|
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[1])
|
|
parser.add_argument('--source', type=Path, required=True)
|
|
parser.add_argument('--expected-sha256', required=True)
|
|
parser.add_argument('--keys', nargs='+', required=True)
|
|
parser.add_argument('--uid', type=int)
|
|
parser.add_argument('--apply', action='store_true')
|
|
args = parser.parse_args()
|
|
root = args.root.resolve()
|
|
target = root / 'persistent'
|
|
source_path = args.source.resolve()
|
|
if source_path == target or not (root / 'AGENTS.md').is_file():
|
|
raise ValueError('source must be a distinct backup and root must be the repository')
|
|
source_bytes = source_path.read_bytes()
|
|
source = json.loads(source_bytes)
|
|
with save_lock(root / 'persistent.lock'):
|
|
before = target.read_bytes()
|
|
if hashlib.sha256(before).hexdigest().lower() != args.expected_sha256.lower():
|
|
raise ValueError('persistent changed since the recovery checkpoint; no write performed')
|
|
current = json.loads(before)
|
|
if not isinstance(current, dict) or not isinstance(source, dict):
|
|
raise ValueError('both saves must be JSON objects')
|
|
patch, preserved, unavailable = {}, [], []
|
|
for key in args.keys:
|
|
if key in current:
|
|
preserved.append(key)
|
|
elif key not in source:
|
|
unavailable.append(key)
|
|
else:
|
|
patch[key] = source[key]
|
|
if 'currency_wars' in patch:
|
|
if args.uid is None:
|
|
raise ValueError('CW recovery requires an explicit UID')
|
|
uid = str(args.uid)
|
|
snapshot = patch['currency_wars'].get(uid)
|
|
catalog = json.loads((root / 'resources/CurrencyWarsCatalog.json').read_bytes())
|
|
if (not isinstance(snapshot, dict) or snapshot.get('uid') != args.uid
|
|
or snapshot.get('format_version') != 1 or not snapshot.get('revision')
|
|
or snapshot.get('client_version') != catalog.get('client_version')
|
|
or snapshot.get('assembly_sha256') != catalog['provenance']['game_assembly_sha256']
|
|
or not base64.b64decode(snapshot.get('wire_data', ''), validate=True)):
|
|
raise ValueError('CW backup does not match the requested account/catalog')
|
|
# The explicit recovery UID never imports another account's run.
|
|
patch['currency_wars'] = {uid: snapshot}
|
|
result = dict(current)
|
|
result.update(patch)
|
|
summary = {'source_sha256': hashlib.sha256(source_bytes).hexdigest(),
|
|
'before_sha256': hashlib.sha256(before).hexdigest(),
|
|
'restored_keys': sorted(patch), 'preserved_existing_keys': preserved,
|
|
'unavailable_keys': unavailable, 'applied': args.apply}
|
|
if args.apply and patch:
|
|
payload = (json.dumps(result, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
|
|
descriptor, temporary = tempfile.mkstemp(prefix='.gac-recovery-', suffix='.tmp', dir=root)
|
|
try:
|
|
with os.fdopen(descriptor, 'wb') as stream:
|
|
stream.write(payload)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, target)
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
summary['after_sha256'] = hashlib.sha256(payload).hexdigest()
|
|
print(json.dumps(summary, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|