Files
sanctuary-beta/scripts/pack.py
T
2026-10-26 06:24:03 +01:00

309 lines
16 KiB
Python

#!/usr/bin/env python3
"""Validate the source pack or stage an installable packwiz directory (stdlib only)."""
import hashlib
import io
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
import packwiz_installer
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()
require(re.fullmatch(r'beta\.(?!000$)[0-9]{3}', values['mod_version']),
'Expected Sanctuary version beta.001 through beta.999')
require(values['mod_version'] == values['pack_version'], 'Sanctuary mod/pack version drift')
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')
require((SOURCE / 'icon.png').read_bytes() == (ROOT / 'mods/sanctuary/src/main/resources/assets/sanctuary/icon.png').read_bytes(), 'Sanctuary mod and instance icons differ')
print('Pack versions, icon 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))
# Verify internal runtime services and the remaining nested dependencies.
with zipfile.ZipFile(jar) as archive:
metadata = json.loads(archive.read('fabric.mod.json'))
require(metadata['version'] == values['mod_version'], 'Stale Sanctuary JAR')
require(archive.read(metadata['icon']) == (SOURCE / 'icon.png').read_bytes(), 'Stale Sanctuary icon in JAR')
require('fr/koka/demeure/DemeureService.class' in archive.namelist(), 'Internal Demeure missing')
require('licenses/Demeure-PROVENANCE.md' in archive.namelist(), 'Demeure provenance missing')
require(not any('demeure-' in j['file'] for j in metadata.get('jars', [])), 'Duplicate Demeure mod')
require('fr/koka/sanctuarytest/WorldgenLab.class' in archive.namelist(), 'Sanctuary terrain runtime missing')
require('fr/koka/sanctuarytest/QuickTestMod.class' not in archive.namelist(), 'Lab entrypoint leaked into production')
external = {'jei', 'sound_physics_remastered', 'sodium', 'bobby', 'lithium', 'entityculling', 'voicechat'}
require(not external & metadata['depends'].keys(),
'External client mods must not be required by Sanctuary')
embedded_jei = []
for nested_mod in metadata.get('jars', []):
with zipfile.ZipFile(io.BytesIO(archive.read(nested_mod['file']))) as candidate:
if 'fabric.mod.json' in candidate.namelist():
info = json.loads(candidate.read('fabric.mod.json'))
require(info['id'] not in external - {'jei'},
'External client mod is still nested in Sanctuary: ' + info['id'])
if info['id'] == 'jei':
embedded_jei.append(archive.read(nested_mod['file']))
require(all('licenses/sound-physics-remastered/' + name in archive.namelist()
for name in ('LICENSE', 'PROVENANCE.md')), 'Sound Physics notices missing')
require(len(embedded_jei) == 1, 'Sanctuary must embed exactly one JEI fork')
with zipfile.ZipFile(io.BytesIO(embedded_jei[0])) as jei:
info = json.loads(jei.read('fabric.mod.json'))
require(info['id'] == 'jei' and info['version'] == values['jei_version'], 'JEI metadata drift')
require(info['depends']['minecraft'] == values['minecraft_version'].replace('-pre-', '-pre.'), 'JEI Minecraft version drift')
require(all(path in jei.namelist() for path in ('LICENSE.txt', 'PROVENANCE.md',
'licenses/baked-substring-index.txt', 'licenses/suffixtree.txt')), 'JEI notices missing')
require(len(info.get('jars', [])) == 2, 'JEI runtime libraries missing')
for library in info['jars']:
require(jei.read(library['file']).startswith(b'PK'), 'Invalid nested JEI library')
sound = read_toml(SOURCE / 'mods/sound-physics-remastered.pw.toml')
sound_jar = ROOT / 'build/external-mods' / sound['filename']
require(sound_jar.is_file(), 'Run the Gradle Sound Physics artifact verification first')
require(sound['download']['hash-format'] == 'sha512'
and sound['download']['hash'] == values['sound_physics_sha512']
and hashlib.sha512(sound_jar.read_bytes()).hexdigest() == values['sound_physics_sha512'],
'Separate Sound Physics artifact hash mismatch')
with zipfile.ZipFile(sound_jar) as archive:
info = json.loads(archive.read('fabric.mod.json'))
require(info['id'] == 'sound_physics_remastered' and info['version'] == values['sound_physics_version'], 'Sound Physics version drift')
require(info['depends']['minecraft'] == values['minecraft_version'] + '.x', 'Sound Physics Minecraft version drift')
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)})
write_index(destination, staged_entries)
server = destination / 'server' if jar_url else ROOT / 'build/packwiz-server'
if server.exists():
shutil.rmtree(server)
server.mkdir(parents=True)
server_entries = []
for entry in staged_entries:
if entry['file'] not in ('mods/fabric-api.pw.toml', 'mods/sanctuary.pw.toml', 'mods/' + jar.name,
'mods/lithium.pw.toml', 'mods/simple-voice-chat.pw.toml'):
continue
target = server / entry['file']
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(destination / entry['file'], target)
server_entries.append(entry)
icon = ROOT / 'mods/sanctuary/src/main/resources/assets/sanctuary/server-icon.png'
shutil.copy2(icon, server / 'server-icon.png')
server_entries.append({'file': 'server-icon.png', 'hash': sha256(icon)})
write_index(server, server_entries, name='Sanctuary Server')
verify_server(server)
print('Client pack staged at ' + str(destination))
print('Server pack staged at ' + str(server))
def write_index(destination, entries, name=None):
lines = ['hash-format = "sha256"', '']
for entry in sorted(entries, key=lambda e: e['file']):
lines.append('[[files]]')
lines.extend(key + ' = ' + json.dumps(value) for key, value in entry.items())
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')
if name:
manifest = manifest.replace('name = "Sanctuary"', 'name = ' + json.dumps(name), 1)
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)
def verify_server(base):
manifest, entries = check_index(base)
values = properties()
require(manifest['version'] == values['pack_version'], 'Server pack version drift')
require(manifest['versions'] == read_toml(SOURCE / 'pack.toml')['versions'], 'Server platform drift')
files = {entry['file'] for entry in entries}
sanctuary = 'mods/sanctuary.pw.toml' if 'mods/sanctuary.pw.toml' in files else 'mods/sanctuary-' + values['mod_version'] + '.jar'
require(files == {'mods/fabric-api.pw.toml', sanctuary, 'mods/lithium.pw.toml',
'mods/simple-voice-chat.pw.toml', 'server-icon.png'},
'Server pack must contain the shared mods and the Sanctuary server icon')
icon = (base / 'server-icon.png').read_bytes()
require(icon == (ROOT / 'mods/sanctuary/src/main/resources/assets/sanctuary/server-icon.png').read_bytes()
and icon[:8] == b'\x89PNG\r\n\x1a\n'
and int.from_bytes(icon[16:20], 'big') == 64
and int.from_bytes(icon[20:24], 'big') == 64, 'Server icon must be the verified 64x64 PNG')
for entry in entries:
if entry.get('metafile'):
require(read_toml(base / entry['file'])['side'] == 'both', 'Client mod in server pack')
return manifest, entries
def prism(pack_url):
"""Create an importable launcher template; never modify an installed instance."""
https_url(pack_url)
values, _ = check()
installer = packwiz_installer.build()
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" '
'--bootstrap-no-update '
'--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=sanctuary-beta', '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-' + values['pack_version'] + '.zip')
destination.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as archive:
files = {'instance.cfg': config.encode(), 'sanctuary-beta.png': (SOURCE / 'icon.png').read_bytes(),
'mmc-pack.json': (json.dumps(components, indent=2) + '\n').encode(),
'minecraft/packwiz-installer-bootstrap.jar': bootstrap,
'minecraft/packwiz-installer.jar': installer}
for name, data in files.items():
entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
entry.compress_type = zipfile.ZIP_DEFLATED
entry.external_attr = 0o100644 << 16
archive.writestr(entry, data)
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] == 'prism')
or (len(sys.argv) == 3 and sys.argv[1] == 'release'),
'Usage: python3 scripts/pack.py check|assemble|release <sanctuary-https-url>|prism <pack-https-url>')
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()