237 lines
12 KiB
Python
237 lines
12 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
|
|
|
|
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))
|
|
# Demeure is autonomous but distributed as a Fabric nested mod in Sanctuary.
|
|
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')
|
|
nested = 'META-INF/jars/demeure-' + values['mod_version'] + '.jar'
|
|
require({'file': nested} in metadata.get('jars', []), 'Demeure nested mod is not declared')
|
|
data = archive.read(nested)
|
|
built_demeure = ROOT / 'mods/demeure/build/libs' / ('demeure-' + values['mod_version'] + '.jar')
|
|
require(built_demeure.is_file() and data == built_demeure.read_bytes(), 'Stale nested Demeure JAR')
|
|
with zipfile.ZipFile(io.BytesIO(data)) as demeure:
|
|
info = json.loads(demeure.read('fabric.mod.json'))
|
|
require(info['id'] == 'demeure' and info['version'] == values['mod_version'], 'Demeure metadata drift')
|
|
require('LICENSE_demeure' in demeure.namelist() and 'PROVENANCE.md' in demeure.namelist(), 'Demeure notices missing')
|
|
jei_path = f"META-INF/jars/jei-sanctuary-{values['minecraft_version']}-{values['jei_version']}.jar"
|
|
require({'file': jei_path} in metadata.get('jars', []), 'JEI source fork is not declared')
|
|
jei_data = archive.read(jei_path)
|
|
built_jei = ROOT / 'mods/jei/build/libs' / Path(jei_path).name
|
|
require(built_jei.is_file() and jei_data == built_jei.read_bytes(), 'Stale JEI source fork')
|
|
with zipfile.ZipFile(io.BytesIO(jei_data)) 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')
|
|
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 - ' + values['pack_version'], '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-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.write(SOURCE / 'icon.png', 'sanctuary-beta.png')
|
|
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 <jar-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()
|