#!/usr/bin/env python3 """Validate the source pack or stage an installable packwiz directory (stdlib only).""" import hashlib import json import os from pathlib import Path import re import shutil import sys from urllib.parse import urlsplit from urllib.request import urlopen import zipfile try: import tomllib except ImportError: # macOS ships Python 3.9; use an installed modern Python without pip packages. for name in ('python3.14', 'python3.13', 'python3.12', 'python3.11'): candidate = shutil.which(name) if candidate and Path(candidate).resolve() != Path(sys.executable).resolve(): os.execv(candidate, [candidate, *sys.argv]) raise SystemExit('Python 3.11+ is required for pack verification (tomllib).') ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / 'packwiz' BOOTSTRAP_URL = ('https://github.com/packwiz/packwiz-installer-bootstrap/releases/' 'download/v0.0.3/packwiz-installer-bootstrap.jar') BOOTSTRAP_SHA256 = 'a8fbb24dc604278e97f4688e82d3d91a318b98efc08d5dbfcbcbcab6443d116c' def read_toml(path): return tomllib.loads(path.read_text(encoding='utf-8')) def sha256(path): return hashlib.sha256(path.read_bytes()).hexdigest() def properties(): return dict(line.split('=', 1) for line in (ROOT / 'gradle.properties').read_text().splitlines() if line.strip() and not line.lstrip().startswith('#')) def require(condition, message): if not condition: raise SystemExit(message) def indexed_path(base, relative): path = (base / relative).resolve() require(path.is_relative_to(base.resolve()), 'Index path escapes pack: ' + relative) require(path.is_file(), 'Missing indexed file: ' + str(path)) return path def check_index(base): pack = read_toml(base / 'pack.toml') index_path = indexed_path(base, pack['index']['file']) require(pack['index']['hash-format'] == 'sha256', 'Pack index must use sha256') require(sha256(index_path) == pack['index']['hash'], 'Stale pack hash; run packwiz refresh') index = read_toml(index_path) require(index['hash-format'] == 'sha256', 'Index files must use sha256') entries = index.get('files', []) require(len({entry['file'] for entry in entries}) == len(entries), 'Duplicate index entry') for entry in entries: path = indexed_path(base, entry['file']) require(sha256(path) == entry['hash'], 'Stale index hash: ' + entry['file']) if entry.get('metafile'): metadata = read_toml(path) download = metadata['download'] require(download['url'].startswith('https://'), 'Dependency URL must use HTTPS') algorithm = download['hash-format'] require(algorithm in ('sha256', 'sha512'), 'Dependency needs SHA-256/512') length = 64 if algorithm == 'sha256' else 128 require(re.fullmatch('[0-9a-f]{' + str(length) + '}', download['hash']), 'Invalid dependency download hash') return pack, entries def check(): values = properties() pack, entries = check_index(SOURCE) require(pack['versions']['minecraft'] == values['minecraft_version'], 'Minecraft version drift') require(pack['versions']['fabric'] == values['loader_version'], 'Fabric Loader version drift') require(pack['version'] == values['pack_version'], 'Pack version drift') api = read_toml(SOURCE / 'mods/fabric-api.pw.toml') require(api['filename'] == 'fabric-api-' + values['fabric_api_version'] + '.jar', 'Fabric API version drift') require(any(e['file'] == 'mods/fabric-api.pw.toml' and e.get('metafile') for e in entries), 'Fabric API is missing from the index') print('Pack versions and index hashes verified.') return values, entries def https_url(value): parsed = urlsplit(value) require(parsed.scheme == 'https' and parsed.hostname and not parsed.username and not parsed.password and not parsed.fragment and not re.search(r'[\s"\'\\]', value), 'Expected a public HTTPS URL') return value def assemble(jar_url=None): values, entries = check() jar = ROOT / 'mods/sanctuary/build/libs' / ('sanctuary-' + values['mod_version'] + '.jar') require(jar.is_file(), 'Build Sanctuary before assembling the pack: ' + str(jar)) destination = ROOT / ('build/packwiz-release' if jar_url else 'build/packwiz') # Only this generated staging directory is replaced; never touch a game instance. if destination.exists(): shutil.rmtree(destination) destination.mkdir(parents=True) staged_entries = [] for entry in entries: source = indexed_path(SOURCE, entry['file']) target = destination / entry['file'] target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) staged_entries.append(entry) target = destination / 'mods' / ('sanctuary.pw.toml' if jar_url else jar.name) target.parent.mkdir(parents=True, exist_ok=True) if jar_url: # Keep this metadata path stable so packwiz replaces the previous JAR. target.write_text('\n'.join([ 'name = "Sanctuary"', 'filename = ' + json.dumps(jar.name), 'side = "both"', '', '[download]', 'url = ' + json.dumps(https_url(jar_url)), 'hash-format = "sha256"', 'hash = ' + json.dumps(sha256(jar)), '', ]), encoding='utf-8') staged_entries.append({'file': 'mods/sanctuary.pw.toml', 'hash': sha256(target), 'metafile': True}) else: shutil.copy2(jar, target) staged_entries.append({'file': 'mods/' + jar.name, 'hash': sha256(target)}) lines = ['hash-format = "sha256"', ''] for entry in sorted(staged_entries, key=lambda e: e['file']): lines.append('[[files]]') for key, value in entry.items(): lines.append(key + ' = ' + json.dumps(value)) lines.append('') index_path = destination / 'index.toml' index_path.write_text('\n'.join(lines), encoding='utf-8') manifest = (SOURCE / 'pack.toml').read_text(encoding='utf-8') manifest, count = re.subn(r'(?m)^hash = "[a-f0-9]+"$', 'hash = "' + sha256(index_path) + '"', manifest) require(count == 1, 'Expected a single pack index hash') (destination / 'pack.toml').write_text(manifest, encoding='utf-8') check_index(destination) print('Pack staged at ' + str(destination)) def prism(pack_url): """Create an importable launcher template; never modify an installed instance.""" https_url(pack_url) values, _ = check() with urlopen(BOOTSTRAP_URL, timeout=60) as response: bootstrap = response.read() require(hashlib.sha256(bootstrap).hexdigest() == BOOTSTRAP_SHA256, 'Unexpected packwiz bootstrap SHA-256') command = ('"$INST_JAVA" -jar "$INST_MC_DIR/packwiz-installer-bootstrap.jar" ' '--bootstrap-main-jar "$INST_MC_DIR/packwiz-installer.jar" ' '--pack-folder "$INST_MC_DIR" --multimc-folder "$INST_DIR" ' + pack_url) # Prism instance.cfg uses Qt INI escaping for quotation marks. config = '\n'.join([ '[General]', 'ConfigVersion=1.3', 'InstanceType=OneSix', 'name=Sanctuary Beta', 'iconKey=default', 'AutomaticJava=true', 'OverrideJavaLocation=false', 'IgnoreJavaCompatibility=false', 'OverrideMemory=true', 'MinMemAlloc=512', 'MaxMemAlloc=4096', 'ManagedPack=false', 'OverrideCommands=true', 'LogPrePostOutput=true', 'PreLaunchCommand=' + command.replace('"', '\\"'), 'notes=Sanctuary Beta : mise a jour packwiz a chaque lancement.', '', ]) components = { 'formatVersion': 1, 'components': [ {'uid': 'net.minecraft', 'version': values['minecraft_version'], 'important': True}, {'uid': 'net.fabricmc.fabric-loader', 'version': values['loader_version']}, ], } destination = ROOT / 'build/Sanctuary-Prism-auto-update.zip' destination.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as archive: archive.writestr('instance.cfg', config) archive.writestr('mmc-pack.json', json.dumps(components, indent=2) + '\n') archive.writestr('minecraft/packwiz-installer-bootstrap.jar', bootstrap) print('Prism auto-update template: ' + str(destination)) print('Pre-launch command: ' + command) if __name__ == '__main__': require((len(sys.argv) == 2 and sys.argv[1] in ('check', 'assemble')) or (len(sys.argv) == 3 and sys.argv[1] in ('release', 'prism')), 'Usage: python3 scripts/pack.py check|assemble|release |prism ') if sys.argv[1] == 'release': assemble(https_url(sys.argv[2])) elif sys.argv[1] == 'prism': prism(sys.argv[2]) elif sys.argv[1] == 'assemble': assemble() else: check()