mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-27 19:54:40 +00:00
131 lines
5.4 KiB
Python
131 lines
5.4 KiB
Python
"""Manifest-driven program updates. User data is never release-owned."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import time
|
|
|
|
PROTECTED = {'data', 'assets', 'maps', 'logs', 'saves', 'settings', 'mods', 'custom-models', 'screenshots'}
|
|
REQUIRED = {'skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe'}
|
|
|
|
def safe_name(name):
|
|
if not isinstance(name, str) or not name or '\\' in name or ':' in name or name.startswith('/'):
|
|
raise ValueError('Unsafe program path')
|
|
parts = name.split('/')
|
|
if any(not p or p in ('.', '..') or p[-1] in ' .' or any(ord(c)<32 for c in p)
|
|
or p.split('.')[0].upper() in {'CON','PRN','AUX','NUL',*(f'COM{i}' for i in range(10)),*(f'LPT{i}' for i in range(10))} for p in parts):
|
|
raise ValueError('Unsafe program path')
|
|
if parts[0].lower() in PROTECTED or parts[0].startswith('.'):
|
|
raise ValueError('Release cannot manage user data')
|
|
return name
|
|
|
|
def manifest_files(meta):
|
|
files = meta.get('files')
|
|
if not isinstance(files, dict) or not REQUIRED <= files.keys() or len(files)>20000:
|
|
raise ValueError('Missing program hashes')
|
|
seen = set()
|
|
for name, digest in files.items():
|
|
safe_name(name)
|
|
if name.lower()=='release.json' or name.lower() in seen or not isinstance(digest,str) or not re.fullmatch('[0-9a-f]{64}',digest):
|
|
raise ValueError('Invalid program manifest')
|
|
seen.add(name.lower())
|
|
for name in seen:
|
|
if any('/'.join(name.split('/')[:i]) in seen for i in range(1,len(name.split('/')))):
|
|
raise ValueError('Program file/directory conflict')
|
|
return files
|
|
|
|
def safe_paths(root, names):
|
|
for name in names:
|
|
safe_name(name)
|
|
for path in (root/name, root/'.update-transaction/new'/name, root/'.update-transaction/old'/name):
|
|
while path != root:
|
|
if path.is_symlink() or path.is_junction():
|
|
raise ValueError('Updater paths cannot contain links or junctions')
|
|
path=path.parent
|
|
|
|
def mismatches(root, meta):
|
|
files=manifest_files(meta)
|
|
safe_paths(root, files)
|
|
bad=[]
|
|
for name,digest in files.items():
|
|
try:
|
|
with (root/name).open('rb') as f: actual=hashlib.file_digest(f,'sha256').hexdigest()
|
|
except OSError: actual=None
|
|
if actual!=digest: bad.append(name)
|
|
return bad
|
|
|
|
def read(path, default=None):
|
|
try: return json.loads(path.read_text(encoding='utf-8-sig'))
|
|
except (OSError,ValueError): return default
|
|
|
|
def atomic(path, value):
|
|
path.parent.mkdir(parents=True,exist_ok=True)
|
|
temp=path.with_suffix('.tmp')
|
|
with temp.open('w',encoding='utf-8') as f:
|
|
json.dump(value,f);f.flush();os.fsync(f.fileno())
|
|
os.replace(temp,path)
|
|
|
|
def retry(op):
|
|
deadline=time.monotonic()+45
|
|
while True:
|
|
try:return op()
|
|
except OSError:
|
|
if time.monotonic()>=deadline:raise
|
|
time.sleep(.25)
|
|
|
|
def rollback(root,tx):
|
|
journal=read(tx/'journal.json')
|
|
if journal is None:return
|
|
if journal.get('protocol')==1:
|
|
# Earlier updaters only backed up this fixed list.
|
|
names=['skate3rust.exe','support/skate3setup.exe','support/skate3update.exe',
|
|
'steam-relay/skate-steam-relay.exe','steam-relay/steam_api64.dll','release.json']
|
|
present=[n for n in names if (tx/'old'/n).is_file()]
|
|
elif journal.get('protocol')==2:
|
|
names=journal['names'];present=journal['present']
|
|
if not isinstance(names,list) or not isinstance(present,list) or len(names)>40001 or not set(present)<=set(names):
|
|
raise ValueError('Invalid recovery journal')
|
|
else:raise ValueError('Unknown update journal')
|
|
safe_paths(root,names)
|
|
for name in reversed(names):
|
|
dest=root/name
|
|
if name in present:
|
|
dest.parent.mkdir(parents=True,exist_ok=True)
|
|
retry(lambda: shutil.copy2(tx/'old'/name,dest))
|
|
else:retry(lambda: dest.unlink(missing_ok=True))
|
|
(tx/'journal.json').unlink()
|
|
|
|
def install(root,tx):
|
|
if (tx/'journal.json').exists():raise ValueError('Recover interrupted update first')
|
|
meta=read(tx/'new/release.json',{})
|
|
new=manifest_files(meta)
|
|
old_meta=read(root/'release.json',{})
|
|
old=manifest_files(old_meta)
|
|
names=sorted(set(new)|set(old))+['release.json']
|
|
safe_paths(root,names)
|
|
if mismatches(tx/'new',meta):raise ValueError('Staged program checksum mismatch')
|
|
present=[]
|
|
# Keep the journal absent until every backup has completed.
|
|
for name in names:
|
|
src=root/name
|
|
if src.exists():
|
|
if not src.is_file():raise ValueError('Program path is not a file: '+name)
|
|
backup=tx/'old'/name;backup.parent.mkdir(parents=True,exist_ok=True)
|
|
shutil.copy2(src,backup);present.append(name)
|
|
atomic(tx/'journal.json',{'protocol':2,'names':names,'present':present})
|
|
try:
|
|
for name in names[:-1]:
|
|
dest=root/name
|
|
if name in new:
|
|
dest.parent.mkdir(parents=True,exist_ok=True)
|
|
retry(lambda: os.replace(tx/'new'/name,dest))
|
|
else:retry(lambda: dest.unlink(missing_ok=True))
|
|
if mismatches(root,meta):raise ValueError('Installed program checksum mismatch')
|
|
# Publish version only after the entire program verifies.
|
|
retry(lambda: os.replace(tx/'new/release.json',root/'release.json'))
|
|
(tx/'journal.json').unlink()
|
|
except Exception:
|
|
rollback(root,tx)
|
|
raise
|