Harden updater provenance recovery and private-release access; document validation

This commit is contained in:
chazmm committed 2026-09-08 20:15:17 +01:00
1 parent 069f153038
commit 3b0cf5359a
7 files changed
+242 -15

No files matched your search

+2
View File
@@ -16,6 +16,8 @@ jobs:
python-version: '3.13'
- name: Install setup packager
run: python -m pip install pyinstaller==6.19.0 numpy==2.2.6 Pillow==11.3.0
- name: Check updater without starting game
run: python -m unittest discover -s tools -p test_updater.py -v
- name: Build Windows package
shell: pwsh
env:
+1
View File
@@ -78,6 +78,7 @@ try {
Copy-Item -LiteralPath docs/installation.md -Destination "$stage/docs/installation.md"
Copy-Item -LiteralPath docs/retail-renderer.md -Destination "$stage/docs/retail-renderer.md"
Copy-Item -LiteralPath docs/crash-reports.md -Destination "$stage/docs/crash-reports.md"
Copy-Item -LiteralPath docs/updates.md -Destination "$stage/docs/updates.md"
New-Item -ItemType Directory -Path "$stage/licenses" -Force | Out-Null
Copy-Item -LiteralPath tools/vendor/utt/LICENSE -Destination "$stage/licenses/UTT.txt"
Copy-Item -LiteralPath tools/vendor/university/LICENSE-PROJECT.md -Destination "$stage/licenses/CustomEngineLayer.txt"
+3
View File
@@ -17,6 +17,9 @@ fn main() {
}
let git = |args: &[&str]| Command::new("git").args(args).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned());
let revision = git(&["rev-parse", "HEAD"]).unwrap_or_else(|| "revision-unavailable".into());
println!("cargo:rerun-if-env-changed=GITHUB_RUN_NUMBER");
println!("cargo:rustc-env=SKATE_RELEASE_REVISION={revision}");
println!("cargo:rustc-env=SKATE_RELEASE_BUILD={}", env::var("GITHUB_RUN_NUMBER").unwrap_or_else(|_| "0".into()));
let dirty = git(&["status", "--porcelain", "--untracked-files=normal"]).map(|s| !s.is_empty());
let compiler = Command::new(env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("--version").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).unwrap_or_default();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
+1
View File
@@ -23,6 +23,7 @@ fn helper_command(recover: bool, automatic: bool) -> Result<(Command, PathBuf, P
let signal = temp.join("ready");
let request = temp.join("request.json");
let data = serde_json::json!({
"revision": env!("SKATE_RELEASE_REVISION"), "build": env!("SKATE_RELEASE_BUILD"),
"root": root, "signal": signal, "automatic": automatic, "recover": recover,
"cwd": std::env::current_dir().map_err(|e| e.to_string())?,
"args": std::env::args().skip(1).collect::<Vec<_>>()
+104
View File
@@ -0,0 +1,104 @@
# Player updates
Pause → **Updates** opens a native desktop update window. Select **Stable**
(default) or **Latest** (includes experimental prereleases), then **Check now**.
Release notes appear as plain, scrollable text. **Update** authorizes downloading,
verification, a clean game exit, replacement and restart. **Cancel** closes the
offer, or cancels a download before shutdown. No installation occurs on a check.
Save any gameplay you want to retain before choosing Update.
Startup checks run in a separate process, at most once per six hours per channel.
Offline/rate-limit/check failures are silent at startup and visible in manual
checks. Manual checks bypass the six-hour interval. Switching channels clears the
old offer and performs a fresh check; channel changes are disabled during work.
Switching to Stable never downgrades an already installed experimental build;
it waits for a newer stable build. Cancelled offers are checked again on a later
eligible startup, or through the menu.
The helper replaces only `skate3rust.exe`, `support/skate3setup.exe`,
`support/skate3update.exe`, and `release.json`. Assets, maps, settings, mods,
character libraries and setup's installation marker are never replaced. The same
working directory and arguments are used for restart; existing asset selection
continues to work. Packaged releases are required; developer binaries do not
silently adopt a downloaded build.
# Package protocol and release maintenance
`release.json` accompanies the ZIP and is also inside it. Schema 1 identifies the
repository, `windows-x64` target, tag, full source revision, monotonically increasing
release-workflow run number (`build`), and SHA-256 of all three program files.
The executable embeds its revision and build number; its local metadata and hash
must match before checking. Local builds use build 0 and are not update sources.
Build numbers, not lexical tags, timestamps, GitHub's chosen latest release, or
the workspace's currently fixed Cargo version, determine upgrade ordering. Never
reset/replace the release workflow's run-number sequence without a protocol
migration. Rerunning a build preserves its identity and does not offer an upgrade
to an installation of that build. Publish a new release/run to ship another build.
No downgrades are offered across channels. Equal build IDs are not upgrades.
The updater scans pages of 100 published releases, rejects drafts, filters the
channel, and requires the ZIP, checksum and compatible metadata. It selects the
highest build, using release ID only as a deterministic tie breaker. A scan that
exceeds its page/time bound fails rather than claiming the player is up to date.
Older releases without this metadata are skipped. Existing installations predating
the updater need one manual portable-package upgrade to bootstrap it.
At implementation time the configured repository was private with no releases.
Normal players need publicly accessible releases. For private testing, launch with
`SKATE_UPDATE_GITHUB_TOKEN` set to a token with read access to repository contents.
The updater does not store this credential; it uses authenticated API asset URLs
and strips authorization on redirects. Never place tokens in launch arguments,
tracked scripts or release packages. No repository visibility or release was
changed by this implementation.
The ZIP checksum is mandatory; GitHub's asset digest is also checked when present.
The internal/external manifests and individual executable hashes must agree.
This trusts the project's GitHub release publisher and HTTPS, not a separate
signing key. Archive paths, duplicate names, links and oversized expansion are
rejected. Only fixed program filenames are written; notes and manifest paths are
never executed. No shell is used. The helper runs from a temporary copy so its
installed binary can also be replaced.
# Failure recovery
Downloads stage under `.update-transaction/new` before requesting a successful
Bevy exit, which the crash supervisor treats as normal. Windows replacement is
retried for up to 45 seconds per file; other running game instances can prevent
replacement. An OS lock serializes updater instances in the same installation.
All four previous files are backed up before the durable journal is published.
Replacement failures restore those backups and restart the old program when
rollback succeeds. Do not close the installer during replacement.
If interrupted, the next normal launch detects the journal and exits to a copied
helper, which restores the old program before restarting. Close other game
instances if recovery cannot replace their locked binaries. Backups remain in
`.update-transaction/old`; keep this directory until recovery succeeds. If the
game executable itself is missing or externally damaged, restore those four backup
files manually or unpack the portable release over the program files. User data
does not need extraction again. Recovery cannot guarantee detection of semantic
bugs in a successfully installed new game; backups are retained for manual return.
# Validation
Automated, isolated checks: `python -m unittest discover -s tools -p test_updater.py -v`.
The release workflow runs these before packaging. Rust integration can be checked
with `cargo check -p skate-game --bin skate3rust --no-default-features`.
Player manual checklist (use a disposable copy of a packaged installation):
- Start an older packaged build: a newer eligible release shows notes and Update/Cancel;
playing remains possible while checking. Cancel leaves the executable unchanged.
- Open Pause → Updates, check manually, and switch Stable/Latest. Verify prerelease
filtering, persisted selection after restart, and no stale offer after switching.
- Choose Update, then cancel during download: game stays open and program files
remain unchanged. Check again and finish: progress, clean exit, one restart,
correct version, no crash dialog, no ISO/setup prompt, preserved player data.
- Disconnect networking: startup stays playable without a dialog; manual check
explains the failure. Repeat with a simulated rate-limit response.
- In an isolated fixture, corrupt the ZIP/checksum: failure must precede shutdown.
Hold a target file locked or deny a replacement: verify rollback and retained
backups. Interrupt after the journal is written, then launch again: verify recovery.
GitHub references: [release listing, pagination and latest-release semantics](https://docs.github.com/en/rest/releases/releases),
[asset digests](https://docs.github.com/en/rest/releases/assets).
+29 -1
View File
@@ -13,7 +13,7 @@ import updater as u
def metadata(build=2):
return dict(schema=1, repository=u.REPO, target='windows-x64', build=build,
tag=f'v{build}', revision='a' * 40)
tag=f'v{build}', revision='a' * 40, files={n: 'b'*64 for n in u.FILES[:-1]})
class UpdaterTests(unittest.TestCase):
@@ -102,6 +102,34 @@ class UpdaterTests(unittest.TestCase):
self.assertEqual((root/'player-data').read_bytes(), b'untouched')
self.assertFalse((tx/'journal.json').exists())
def test_success_and_interrupted_recovery(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp); tx = root/'.update-transaction'
for name in u.FILES:
(root/name).parent.mkdir(parents=True, exist_ok=True)
(root/name).write_bytes(b'old')
(tx/'new'/name).parent.mkdir(parents=True, exist_ok=True)
(tx/'new'/name).write_bytes(b'new')
u.install(root, tx)
self.assertTrue(all((root/n).read_bytes() == b'new' for n in u.FILES))
# Simulate interrupted replacement with durable backups/journal.
u.atomic(tx/'journal.json', {'protocol': 1})
u.rollback(root, tx)
self.assertTrue(all((root/n).read_bytes() == b'old' for n in u.FILES))
self.assertFalse((tx/'journal.json').exists())
def test_offline_and_rate_limit(self):
with patch.object(u, 'fetch', side_effect=OSError('offline')), self.assertRaises(OSError):
u.discover(metadata(1), 'Stable', threading.Event())
error = u.urllib.error.HTTPError(u.API, 429, 'rate limit', {}, None)
with patch.object(u.urllib.request.OpenerDirector, 'open', side_effect=error), self.assertRaisesRegex(ValueError, 'rate limit'):
u.fetch(u.API, threading.Event(), 100)
def test_redirect_never_forwards_private_token(self):
req = u.urllib.request.Request(u.API + '/assets/1', headers={'Authorization': 'Bearer test-only'})
redirected = u.DownloadRedirect().redirect_request(req, None, 302, 'Found', {}, 'https://release-assets.githubusercontent.com/file')
self.assertIsNone(redirected.get_header('Authorization'))
if __name__ == '__main__':
unittest.main()
+102 -14
View File
@@ -8,10 +8,10 @@ import queue
import re
import shutil
import subprocess
import tempfile
import threading
import time
import urllib.request
import urllib.error
import zipfile
REPO = 'SK8-ENGINE/skate-3-rust-engine'
@@ -21,10 +21,28 @@ FILES = ('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe'
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')
temp.write_text(json.dumps(value), encoding='utf-8')
with temp.open('w', encoding='utf-8') as output:
json.dump(value, output)
output.flush()
os.fsync(output.fileno())
os.replace(temp, path)
@@ -36,23 +54,61 @@ def read_json(path, default=None):
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 re.fullmatch(r'[0-9a-f]{40}', m.get('revision', ''))):
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')
req = urllib.request.Request(url, headers={'User-Agent': 'Skate3RustEngine-Updater/1',
'X-GitHub-Api-Version': '2022-11-28'})
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()
with urllib.request.urlopen(req, timeout=15) as response:
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:
@@ -75,6 +131,7 @@ def eligible(release, channel):
def discover(current, channel, cancel):
current_build = identity(current)
candidates = []
deadline = time.monotonic() + 120
# Exhaust pagination; never silently claim current after a truncated scan.
@@ -83,15 +140,18 @@ def discover(current, channel, cancel):
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(assets['release.json']['browser_download_url'], cancel, 65536))
meta = json.loads(fetch(asset_url(assets['release.json']), cancel, 65536))
build = identity(meta)
if meta['tag'] != release['tag_name'] or build <= identity(current):
program_metadata(meta)
if meta['tag'] != release['tag_name'] or build <= current_build:
continue
except (ValueError, KeyError):
continue
@@ -105,10 +165,10 @@ def discover(current, channel, cancel):
def stage(candidate, directory, cancel, progress):
_, _, release, assets, meta = candidate
checksum = fetch(assets[PACKAGE + '.sha256']['browser_download_url'], cancel, 1024).decode('ascii').split()
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(assets[PACKAGE]['browser_download_url'], cancel, 1024 * 1024 * 1024, progress)
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')
@@ -156,6 +216,7 @@ def retry(operation):
def rollback(root, tx):
safe_local_paths(root)
journal = read_json(tx / 'journal.json')
if journal is None:
return
@@ -168,6 +229,7 @@ def rollback(root, tx):
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
@@ -183,12 +245,14 @@ def install(root, tx):
raise
def main():
def main(request=None):
parser = argparse.ArgumentParser()
parser.add_argument('--request', type=Path, required=True)
args = parser.parse_args()
request = read_json(args.request)
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')
@@ -257,6 +321,9 @@ def main():
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()
@@ -266,7 +333,16 @@ def main():
pass
status.set('Checking GitHub…')
selected = channel.get()
work('checked', lambda: discover(read_json(root / 'release.json', {}), selected, cancel))
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
@@ -376,4 +452,16 @@ def main():
if __name__ == '__main__':
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