#!/usr/bin/env python3 from __future__ import annotations import filecmp import json import os import shutil import subprocess import sys import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_BINARY = REPO_ROOT / "build" / "m2pack" ENV_BINARY = "M2PACK_BINARY" def resolve_binary() -> Path: env_value = os.environ.get(ENV_BINARY) if env_value: candidate = Path(env_value).expanduser() if candidate.is_file(): return candidate if DEFAULT_BINARY.is_file(): return DEFAULT_BINARY raise FileNotFoundError( f"m2pack binary not found. Build {DEFAULT_BINARY} or set {ENV_BINARY}." ) def run_json(binary: Path, *args: str) -> dict: proc = subprocess.run( [str(binary), *args, "--json"], cwd=REPO_ROOT, text=True, capture_output=True, check=False, ) if proc.returncode != 0: detail = proc.stderr.strip() or proc.stdout.strip() or f"exit code {proc.returncode}" raise RuntimeError(f"command failed: {' '.join(args)}\n{detail}") stdout = proc.stdout.strip() if not stdout: return {"ok": True} return json.loads(stdout) def write_asset_tree(root: Path) -> None: (root / "locale" / "de").mkdir(parents=True, exist_ok=True) (root / "icon").mkdir(parents=True, exist_ok=True) (root / "ui").mkdir(parents=True, exist_ok=True) (root / "locale" / "de" / "welcome.txt").write_text( "metin2 secure pack test\n", encoding="utf-8", ) (root / "ui" / "layout.json").write_text( json.dumps( { "window": "inventory", "slots": 90, "theme": "headless-e2e", }, indent=2, ) + "\n", encoding="utf-8", ) (root / "icon" / "item.bin").write_bytes(bytes((i * 13) % 256 for i in range(2048))) def compare_trees(left: Path, right: Path) -> None: comparison = filecmp.dircmp(left, right) if comparison.left_only or comparison.right_only or comparison.diff_files or comparison.funny_files: raise RuntimeError( "tree mismatch: " f"left_only={comparison.left_only}, " f"right_only={comparison.right_only}, " f"diff_files={comparison.diff_files}, " f"funny_files={comparison.funny_files}" ) for child in comparison.common_dirs: compare_trees(left / child, right / child) def main() -> int: binary = resolve_binary() with tempfile.TemporaryDirectory(prefix="m2pack-headless-") as tmp_dir: tmp = Path(tmp_dir) assets = tmp / "assets" keys = tmp / "keys" out_dir = tmp / "out" extracted = tmp / "extracted" client_header = tmp / "M2PackKeys.h" runtime_json = tmp / "runtime-key.json" runtime_blob = tmp / "runtime-key.bin" archive = out_dir / "client.m2p" assets.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True) write_asset_tree(assets) keygen = run_json(binary, "keygen", "--out-dir", str(keys)) build = run_json( binary, "build", "--input", str(assets), "--output", str(archive), "--key", str(keys / "master.key"), "--sign-secret-key", str(keys / "signing.key"), "--key-id", "7", ) listed = run_json(binary, "list", "--archive", str(archive)) verify = run_json( binary, "verify", "--archive", str(archive), "--public-key", str(keys / "signing.pub"), "--key", str(keys / "master.key"), ) diff = run_json( binary, "diff", "--left", str(assets), "--right", str(archive), ) export_client = run_json( binary, "export-client-config", "--key", str(keys / "master.key"), "--public-key", str(keys / "signing.pub"), "--key-id", "7", "--output", str(client_header), ) export_runtime_json = run_json( binary, "export-runtime-key", "--key", str(keys / "master.key"), "--public-key", str(keys / "signing.pub"), "--key-id", "7", "--format", "json", "--output", str(runtime_json), ) export_runtime_blob = run_json( binary, "export-runtime-key", "--key", str(keys / "master.key"), "--public-key", str(keys / "signing.pub"), "--key-id", "7", "--format", "blob", "--output", str(runtime_blob), ) extract = run_json( binary, "extract", "--archive", str(archive), "--output", str(extracted), "--key", str(keys / "master.key"), ) compare_trees(assets, extracted) runtime_obj = json.loads(runtime_json.read_text(encoding="utf-8")) if runtime_obj["key_id"] != 7 or runtime_obj["mapping_name"] != "Local\\M2PackSharedKeys": raise RuntimeError("runtime json payload mismatch") if runtime_blob.stat().st_size != 84: raise RuntimeError(f"runtime blob size mismatch: {runtime_blob.stat().st_size}") header_text = client_header.read_text(encoding="utf-8") if "M2PACK_RUNTIME_MASTER_KEY_REQUIRED = true" not in header_text: raise RuntimeError("client header missing runtime enforcement flag") if "M2PACK_SIGN_KEY_IDS = { 7 }" not in header_text: raise RuntimeError("client header missing key id slot") summary = { "ok": True, "binary": str(binary), "file_count": build["file_count"], "listed_entries": len(listed["entries"]), "verify_ok": verify["ok"], "diff_changed": len(diff["changed"]), "diff_added": len(diff["added"]), "diff_removed": len(diff["removed"]), "extract_entry_count": extract["entry_count"], "runtime_key_id": runtime_obj["key_id"], "runtime_blob_size": runtime_blob.stat().st_size, "artifacts_root": str(tmp), "steps": { "keygen": keygen["ok"], "build": build["ok"], "list": listed["ok"], "verify": verify["ok"], "diff": diff["ok"], "export_client_config": export_client["ok"], "export_runtime_json": export_runtime_json["ok"], "export_runtime_blob": export_runtime_blob["ok"], "extract": extract["ok"], }, } print(json.dumps(summary, indent=2)) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(str(exc), file=sys.stderr) raise SystemExit(1)