"""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()