#!/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 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' 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 assemble(): 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' # 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' / jar.name target.parent.mkdir(parents=True, exist_ok=True) 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('Installable development pack staged at ' + str(destination)) if __name__ == '__main__': require(len(sys.argv) == 2 and sys.argv[1] in ('check', 'assemble'), 'Usage: python3 scripts/pack.py check|assemble') if sys.argv[1] == 'assemble': assemble() else: check()