#!/usr/bin/env python3 """Validate a packwiz pack: index integrity, download resolvability, CF export completeness. Stdlib-only (no tomllib) so it runs on Python 3.9 in CI containers. Parses the limited, machine-generated TOML subset used by packwiz metadata. """ import glob import json import os import re import sys import zipfile PW_TOML = re.compile(r"^(\w[\w.-]*)\s*=\s*(?:\"([^\"]*)\"|([0-9]+))", re.M) SECTION = re.compile(r"^\[\[?([\w.\-]+)\]\]?$", re.M) def parse_toml(path): """Parse the minimal TOML subset packwiz emits: scalars + sections + array-of-tables.""" data = {} current = data array_section = None with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#"): continue m = SECTION.match(line) if m: name = m.group(1) if line.startswith("[["): key = name array_section = key data.setdefault(key, []) current = {} data[key].append(current) else: key = name.replace("-", "_").replace(".", "_") if key not in data or not isinstance(data[key], dict): data[key] = {} current = data[key] array_section = None continue m = PW_TOML.match(line) if m: key = m.group(1).replace("-", "_") value = m.group(2) if m.group(2) is not None else m.group(3) current[key] = value return data def check_integrity(pack_dir): index = parse_toml(os.path.join(pack_dir, "index.toml")) indexed = {e.get("file") for e in index.get("files", []) if e.get("file")} mods = [os.path.basename(f) for f in glob.glob(os.path.join(pack_dir, "mods", "*.pw.toml"))] missing = [f for f in mods if "mods/%s" % f not in indexed] if missing: print("ERROR: pw.toml files missing from index.toml:", missing) sys.exit(1) extra = [f for f in indexed if f.startswith("mods/") and f not in {"mods/%s" % m for m in mods}] if extra: print("ERROR: index.toml entries missing from mods/:", extra) sys.exit(1) errors = 0 for f in mods: data = parse_toml(os.path.join(pack_dir, "mods", f)) d = data.get("download", {}) if not d or not d.get("hash"): print("ERROR: %s missing [download] hash" % f) errors += 1 continue if not d.get("url") and not d.get("mode", "").startswith("metadata:"): print("ERROR: %s missing [download] url or metadata mode" % f) errors += 1 if d.get("url", "").startswith("http://"): print("ERROR: %s uses insecure http url" % f) errors += 1 hf = d.get("hash-format", "sha512") if not re.fullmatch(r"[0-9a-f]+", d["hash"]): print("ERROR: %s hash not hex (%s)" % (f, hf)) errors += 1 if errors: sys.exit(1) print("OK: %d mods indexed, all have [download] url or metadata source" % len(mods)) def check_sanity(pack_dir): bad = [] url_count = 0 meta_count = 0 for f in sorted(glob.glob(os.path.join(pack_dir, "mods", "*.pw.toml"))): d = parse_toml(f).get("download", {}) if d.get("url"): url_count += 1 elif d.get("mode", "").startswith("metadata:"): meta_count += 1 else: bad.append(f) if bad: print("ERROR: mods without download.url or metadata mode:") for f in bad: print(" " + f) sys.exit(1) print("OK: %d direct urls + %d metadata sources = all mods resolvable" % (url_count, meta_count)) def check_cf(pack_dir, zip_path): z = zipfile.ZipFile(zip_path) manifest = json.loads(z.read("manifest.json")) pids = {f["projectID"] for f in manifest["files"]} ov = {n.split("overrides/mods/")[1] for n in z.namelist() if n.startswith("overrides/mods/")} missing = [] for f in glob.glob(os.path.join(pack_dir, "mods", "*.pw.toml")): data = parse_toml(f) if data.get("side") == "server": continue fn = data.get("filename") cf = data.get("update_curseforge", {}).get("project_id") if cf: if int(cf) not in pids: missing.append(fn) elif fn not in ov: missing.append(fn) if missing: print("ERROR: mods missing from CurseForge export:", missing) sys.exit(1) print("OK: CurseForge export complete (%d manifest files + %d overrides)" % (len(pids), len(ov))) def main(): if len(sys.argv) < 2: print("usage: validate_pack.py [cf ]", file=sys.stderr) sys.exit(2) pack_dir = sys.argv[1] cmd = sys.argv[2] if len(sys.argv) > 2 else "integrity" if cmd == "cf": check_cf(pack_dir, sys.argv[3]) elif cmd == "sanity": check_sanity(pack_dir) else: check_integrity(pack_dir) if __name__ == "__main__": main()