Files
skate-3-rust-engine/tools/updater.py
T

380 lines
15 KiB
Python

"""Release updater protocol 1. No game assets, shell commands or archive extractall."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import queue
import re
import shutil
import subprocess
import tempfile
import threading
import time
import urllib.request
import zipfile
REPO = 'SK8-ENGINE/skate-3-rust-engine'
API = f'https://api.github.com/repos/{REPO}/releases'
PACKAGE = 'skate3rust-windows-x64.zip'
FILES = ('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe', 'release.json')
PREFIX = 'skate3rust-windows-x64/'
def atomic(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix('.tmp')
temp.write_text(json.dumps(value), encoding='utf-8')
os.replace(temp, path)
def read_json(path, default=None):
try:
return json.loads(path.read_text(encoding='utf-8-sig'))
except (OSError, ValueError):
return default
def identity(m):
if (m.get('schema') != 1 or m.get('target') != 'windows-x64'
or m.get('repository') != REPO or not isinstance(m.get('build'), int)
or m['build'] <= 0 or not isinstance(m.get('tag'), str)
or not re.fullmatch(r'[0-9a-f]{40}', m.get('revision', ''))):
raise ValueError('Incompatible release metadata')
return m['build']
def fetch(url, cancel, limit, progress=lambda value: None):
# URLs originate only from the fixed repository API, never notes/manifest.
if not (url.startswith(API) or url.startswith(f'https://github.com/{REPO}/releases/download/')):
raise ValueError('Unexpected download URL')
req = urllib.request.Request(url, headers={'User-Agent': 'Skate3RustEngine-Updater/1',
'X-GitHub-Api-Version': '2022-11-28'})
deadline = time.monotonic() + 300
result = bytearray()
with urllib.request.urlopen(req, timeout=15) as response:
if not response.url.startswith('https://'):
raise ValueError('Insecure redirect')
while True:
if cancel.is_set():
raise InterruptedError('Cancelled')
if time.monotonic() > deadline:
raise TimeoutError('Download timed out')
block = response.read(256 * 1024)
if not block:
return bytes(result)
result.extend(block)
if len(result) > limit:
raise ValueError('Download exceeds size limit')
progress(f'Downloaded {len(result) // (1024 * 1024)} MB')
def eligible(release, channel):
return (not release.get('draft') and bool(release.get('published_at'))
and (channel == 'Latest' or not release.get('prerelease')))
def discover(current, channel, cancel):
candidates = []
deadline = time.monotonic() + 120
# Exhaust pagination; never silently claim current after a truncated scan.
for page in range(1, 101):
if time.monotonic() > deadline:
raise TimeoutError('Release check timed out')
releases = json.loads(fetch(f'{API}?per_page=100&page={page}', cancel, 8 * 1024 * 1024))
for release in releases:
if not eligible(release, channel):
continue
assets = {a['name']: a for a in release.get('assets', []) if a.get('state') == 'uploaded'}
if not {PACKAGE, PACKAGE + '.sha256', 'release.json'} <= assets.keys():
continue
try:
meta = json.loads(fetch(assets['release.json']['browser_download_url'], cancel, 65536))
build = identity(meta)
if meta['tag'] != release['tag_name'] or build <= identity(current):
continue
except (ValueError, KeyError):
continue
candidates.append((build, release['id'], release, assets, meta))
if len(releases) < 100:
break
else:
raise ValueError('Too many release pages; check again later')
return max(candidates, key=lambda item: item[:2]) if candidates else None
def stage(candidate, directory, cancel, progress):
_, _, release, assets, meta = candidate
checksum = fetch(assets[PACKAGE + '.sha256']['browser_download_url'], cancel, 1024).decode('ascii').split()
if len(checksum) != 2 or checksum[1] != PACKAGE or not re.fullmatch('[0-9a-fA-F]{64}', checksum[0]):
raise ValueError('Invalid release checksum')
archive = fetch(assets[PACKAGE]['browser_download_url'], cancel, 1024 * 1024 * 1024, progress)
digest = hashlib.sha256(archive).hexdigest()
if digest != checksum[0].lower():
raise ValueError('Package checksum mismatch')
api_digest = assets[PACKAGE].get('digest')
if api_digest and api_digest != 'sha256:' + digest:
raise ValueError('GitHub asset digest mismatch')
import io
with zipfile.ZipFile(io.BytesIO(archive)) as z:
names = set()
total = 0
for info in z.infolist():
name = info.filename
parts = name.rstrip('/').split('/')
if (not name.startswith(PREFIX) or '\\' in name or ':' in name
or any(p in ('', '.', '..') for p in parts)
or name.lower() in names or (info.external_attr >> 16) & 0o170000 == 0o120000):
raise ValueError('Unsafe archive path')
names.add(name.lower())
total += info.file_size
if total > 2 * 1024 * 1024 * 1024:
raise ValueError('Expanded package too large')
for name in FILES:
if cancel.is_set():
raise InterruptedError('Cancelled')
payload = z.read(PREFIX + name)
if name != 'release.json':
if hashlib.sha256(payload).hexdigest() != meta['files'][name]:
raise ValueError('Program checksum mismatch')
elif json.loads(payload.decode('utf-8-sig')) != meta:
raise ValueError('Package identity mismatch')
dest = directory / 'new' / name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(payload)
def retry(operation):
deadline = time.monotonic() + 45
while True:
try:
return operation()
except OSError:
if time.monotonic() >= deadline:
raise
time.sleep(.25)
def rollback(root, tx):
journal = read_json(tx / 'journal.json')
if journal is None:
return
# Fixed paths only, even when recovering a corrupted journal.
for name in reversed(FILES):
backup = tx / 'old' / name
if backup.is_file():
retry(lambda: shutil.copy2(backup, root / name))
(tx / 'journal.json').unlink()
def install(root, tx):
# Back up everything before the first replacement. Journal survives power loss.
for name in FILES:
backup = tx / 'old' / name
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / name, backup)
atomic(tx / 'journal.json', {'protocol': 1})
try:
for name in FILES:
retry(lambda: os.replace(tx / 'new' / name, root / name))
(tx / 'journal.json').unlink()
except Exception:
rollback(root, tx)
raise
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--request', type=Path, required=True)
args = parser.parse_args()
request = read_json(args.request)
root = Path(request['root']).resolve()
tx = root / '.update-transaction'
# OS releases the lock after crashes; concurrent game instances cannot install.
lock = (root / '.update.lock').open('a+b')
lock.write(b'0')
lock.flush()
lock.seek(0)
if os.name == 'nt':
import msvcrt
try:
msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
return
if request.get('recover'):
time.sleep(3)
try:
rollback(root, tx)
except Exception as error:
import tkinter.messagebox
tkinter.messagebox.showerror('Update recovery', 'Could not restore the previous program. Close other game instances and try again. Backups remain in .update-transaction/old.\n' + str(error))
return
lock.close()
subprocess.Popen([str(root / 'skate3rust.exe'), *request['args']], cwd=request['cwd'])
return
import tkinter as tk
from tkinter import ttk
win = tk.Tk()
win.title('Skate 3 Rust Engine — Updates')
win.geometry('660x520')
automatic = request['automatic']
if automatic:
win.withdraw()
settings_path = Path(os.environ.get('LOCALAPPDATA', str(Path.home()))) / 'Skate3RustEngine/settings/updates.json'
settings = read_json(settings_path, {})
channel = tk.StringVar(value=settings.get('channel') if settings.get('channel') in ('Stable', 'Latest') else 'Stable')
status = tk.StringVar(value='Ready to check')
ttk.Label(win, text='Updates — Update downloads, then closes and restarts the game.').pack(pady=8)
choice = ttk.Combobox(win, textvariable=channel, values=('Stable', 'Latest'), state='readonly')
choice.pack()
ttk.Label(win, text='Stable: published releases. Latest: includes experimental prereleases.').pack()
notes = tk.Text(win, wrap='word', height=19)
notes.pack(fill='both', expand=True, padx=12, pady=8)
notes.configure(state='disabled')
ttk.Label(win, textvariable=status, wraplength=620).pack()
events = queue.Queue()
cancel = threading.Event()
candidate = None
busy = False
generation = 0
closing = False
def work(kind, operation):
nonlocal busy
busy = True
update.configure(state='disabled')
check.configure(state='disabled')
choice.configure(state='disabled')
token = generation
def run():
try:
events.put((token, kind, operation()))
except Exception as e:
events.put((token, 'error', str(e)))
threading.Thread(target=run, daemon=True).start()
def check_now():
nonlocal candidate, generation
generation += 1
candidate = None
cancel.clear()
settings['channel'] = channel.get()
settings[channel.get()] = time.time()
try:
atomic(settings_path, settings)
except OSError:
pass
status.set('Checking GitHub…')
selected = channel.get()
work('checked', lambda: discover(read_json(root / 'release.json', {}), selected, cancel))
def do_update():
nonlocal automatic
automatic = False
if candidate is None or busy:
return
cancel.clear()
def prepare():
if (tx / 'journal.json').exists():
raise ValueError('An interrupted update needs recovery. Restart the game first.')
tx.mkdir(exist_ok=True)
stage(candidate, tx, cancel, lambda text: events.put((generation, 'progress', text)))
status.set('Downloading and verifying. Cancel keeps the game running.')
work('staged', prepare)
def close():
nonlocal closing
if busy:
cancel.set()
closing = True
status.set('Cancelling…')
else:
win.destroy()
check = ttk.Button(win, text='Check now', command=check_now)
check.pack(side='left', padx=12, pady=12)
update = ttk.Button(win, text='Update', command=do_update, state='disabled')
update.pack(side='left', padx=12)
back = ttk.Button(win, text='Cancel', command=close)
back.pack(side='right', padx=12)
win.protocol('WM_DELETE_WINDOW', close)
choice.bind('<<ComboboxSelected>>', lambda _: check_now())
def poll():
nonlocal candidate, busy, automatic
try:
while True:
token, kind, value = events.get_nowait()
if token != generation:
continue
if kind == 'progress':
status.set(value)
continue
busy = False
if closing:
win.destroy()
return
check.configure(state='normal')
choice.configure(state='readonly')
if kind == 'error':
back.configure(state='normal')
win.protocol('WM_DELETE_WINDOW', close)
status.set('Update unavailable: ' + value[:250] + '. You can keep playing and try again later.')
if automatic:
win.destroy()
return
elif kind == 'checked':
candidate = value
if candidate:
automatic = False
win.deiconify()
notes.configure(state='normal')
notes.delete('1.0', 'end')
notes.insert('end', candidate[4]['tag'] + '\n\n' + (candidate[2].get('body') or 'No release notes.')[:50000])
notes.configure(state='disabled')
update.configure(state='normal')
status.set('New release available. Cancel keeps your current version.')
elif automatic:
win.destroy()
return
else:
status.set('No newer compatible release in this channel.')
elif kind == 'staged':
if cancel.is_set():
continue
status.set('Closing game and installing… Please keep this window open.')
back.configure(state='disabled')
win.protocol('WM_DELETE_WINDOW', lambda: None)
def finish():
Path(request['signal']).write_text('ready', encoding='ascii')
# Wait for game and supervisor to release the PE. Replacement retries
# are the lock authority; no PID reuse or process termination.
time.sleep(3)
try:
install(root, tx)
except Exception:
if not (tx / 'journal.json').exists():
subprocess.Popen([str(root / 'skate3rust.exe'), *request['args']], cwd=request['cwd'])
raise
lock.close()
subprocess.Popen([str(root / 'skate3rust.exe'), *request['args']], cwd=request['cwd'])
work('installed', finish)
elif kind == 'installed':
win.destroy()
return
except queue.Empty:
pass
win.after(100, poll)
if not automatic or time.time() - settings.get(channel.get(), 0) >= 21600:
check_now()
else:
win.destroy()
return
win.after(100, poll)
win.mainloop()
if __name__ == '__main__':
main()