"""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 threading import time import urllib.request import urllib.error 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/' class DownloadRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): if not newurl.startswith('https://'): raise ValueError('Insecure redirect') redirected = super().redirect_request(req, fp, code, msg, headers, newurl) # Private asset requests redirect to signed CDN URLs. Never forward a PAT. if redirected is not None: redirected.remove_header('Authorization') return redirected def asset_url(asset): return asset['url'] if os.environ.get('SKATE_UPDATE_GITHUB_TOKEN') else asset['browser_download_url'] 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 output: json.dump(value, output) output.flush() os.fsync(output.fileno()) 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 not isinstance(m, dict): raise ValueError('Invalid release metadata') 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 isinstance(m.get('revision'), str) or not re.fullmatch(r'[0-9a-f]{40}', m['revision'])): raise ValueError('Incompatible release metadata') return m['build'] def program_metadata(m): identity(m) files = m.get('files') if not isinstance(files, dict) or any(not isinstance(files.get(name), str) or not re.fullmatch('[0-9a-f]{64}', files[name]) for name in FILES[:-1]): raise ValueError('Missing program hashes') def safe_local_paths(root): # Never follow a user junction/symlink into asset stores or another install. paths = [root / name for name in FILES] tx = root / '.update-transaction' paths += [tx / side / name for side in ('old', 'new') for name in FILES] for path in paths: while path != root: if path.is_symlink() or path.is_junction(): raise ValueError('Updater program directories cannot contain links or junctions') path = path.parent def fetch(url, cancel, limit, progress=lambda value: None): if cancel.is_set(): raise InterruptedError('Cancelled') # 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') headers = {'User-Agent': 'Skate3RustEngine-Updater/1', 'X-GitHub-Api-Version': '2022-11-28'} if url.startswith(API): headers['Accept'] = 'application/octet-stream' if '/assets/' in url else 'application/vnd.github+json' token = os.environ.get('SKATE_UPDATE_GITHUB_TOKEN') if token: headers['Authorization'] = 'Bearer ' + token req = urllib.request.Request(url, headers=headers) deadline = time.monotonic() + 300 result = bytearray() try: response = urllib.request.build_opener(DownloadRedirect()).open(req, timeout=15) except urllib.error.HTTPError as error: if error.code in (403, 429): raise ValueError('GitHub rate limit or access restriction; try again later') from error if error.code == 404: raise ValueError('Releases are not publicly available, or private-release access is missing') from error raise with 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): current_build = identity(current) 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 time.monotonic() > deadline: raise TimeoutError('Release check timed out') 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(asset_url(assets['release.json']), cancel, 65536)) build = identity(meta) program_metadata(meta) if meta['tag'] != release['tag_name'] or build <= current_build: 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(asset_url(assets[PACKAGE + '.sha256']), 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(asset_url(assets[PACKAGE]), 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): safe_local_paths(root) 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): safe_local_paths(root) # 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(request=None): parser = argparse.ArgumentParser() parser.add_argument('--request', type=Path, required=True) args = parser.parse_args() request = request or read_json(args.request) args.request.unlink(missing_ok=True) root = Path(request['root']).resolve() safe_local_paths(root) 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 notes.configure(state='normal') notes.delete('1.0', 'end') notes.configure(state='disabled') 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() def perform_check(): current = read_json(root / 'release.json', {}) program_metadata(current) with (root / 'skate3rust.exe').open('rb') as executable: digest = hashlib.file_digest(executable, 'sha256').hexdigest() if (current['revision'] != request['revision'] or str(current['build']) != request['build'] or digest != current['files']['skate3rust.exe']): raise ValueError('This executable does not match its release metadata') return discover(current, selected, cancel) work('checked', perform_check) 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('<>', 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__': # Suppress PyInstaller's generic exception/crash dialog for routine updater # failures, including unwritable portable directories before UI creation. import sys request = read_json(Path(sys.argv[2]), {}) if len(sys.argv) == 3 and sys.argv[1] == '--request' else {} try: main(request) except Exception as error: if request and not request.get('automatic', True): try: import tkinter.messagebox tkinter.messagebox.showerror('Updates unavailable', str(error)[:300] + '\nYou can keep playing. Try again later.') except Exception: pass