Files
GaCy-GO/tools/restore_missing_persistent.py
T

121 lines
5.8 KiB
Python
Raw Normal View History

2026-10-28 18:54:43 +07:00
"""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()