Volver al índice

docs/publicacion/verify-final-jar.py

"""Compare a final JAR with its preserved baseline and current native source.

This is a packaging audit, not a substitute for the server's functional tests.
Only private build artifacts are written; production sources are never edited.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import subprocess
import zipfile
from pathlib import Path


# INPUTS: the final JAR must be supplied explicitly after its build completes.
ROOT = Path(__file__).resolve().parents[2]
HERE = ROOT / 'build/jar-native-audit'
HARNESS = Path(__file__).resolve().parent / 'JarRigExportAudit.java'
SOURCE = ROOT / "src/main/java/ar/com/companeros/horror/client/ParasiteRig.java"
BASELINE = ROOT / "respaldos/antes-contactos-20261007-140143/Raps.jar"
JAVA_BIN = Path("C:/Program Files/Eclipse Adoptium/jdk-17.0.20.8-hotspot/bin")
CLIENT_PREFIX = "ar/com/companeros/horror/client/"
# Cambios visuales permitidos en esta revisión: el compositor de piel vive en
# identity, y las dos texturas son los atlas regenerados del huésped.
ALLOWED_CLIENT_ENTRIES = {"ar/com/companeros/identity/HostSkinAtlas.class"}
ALLOWED_RESOURCE_ENTRIES = {
    "assets/companeros/textures/entity/parasite.png",
    "assets/companeros/textures/entity/parasite_initial.png",
}


def sha(path):
    result = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            result.update(chunk)
    return result.hexdigest()


# ZIP: compare uncompressed entry bytes, independent of timestamps/compression.
def entries(path):
    with zipfile.ZipFile(path) as archive:
        return {info.filename: hashlib.sha256(archive.read(info)).hexdigest()
                for info in archive.infolist() if not info.is_dir()}


def compare_zip(old, new):
    before, after = entries(old), entries(new)
    changed = [{"name": name, "before_sha256": before.get(name), "after_sha256": after.get(name)}
               for name in sorted(set(before) | set(after)) if before.get(name) != after.get(name)]
    backend = [item["name"] for item in changed
               if item["name"].endswith(".class")
               and not item["name"].startswith(CLIENT_PREFIX)
               and item["name"] not in ALLOWED_CLIENT_ENTRIES]
    resources = [item["name"] for item in changed
                 if not item["name"].endswith(".class")
                 and not item["name"].startswith("META-INF/")
                 and item["name"] not in ALLOWED_RESOURCE_ENTRIES]
    return {"baseline_entries": len(before), "final_entries": len(after), "changed_entries": changed,
            "backend_classes_changed": backend, "resources_changed": resources,
            "backend_and_resources_identical": not backend and not resources}


# NATIVE: compile the actual source once; run identical public CLI calls in each.
def export(classpath, destination):
    with destination.open("wb") as stream:
        subprocess.run([str(JAVA_BIN / "java.exe"), "-cp", classpath, "JarRigExportAudit"],
                       stdout=stream, stderr=subprocess.PIPE, check=True, timeout=120)


def compare_exports(left, right):
    rows = []
    with left.open(encoding="utf-8") as original, right.open(encoding="utf-8") as packaged:
        for line_a, line_b in zip(original, packaged, strict=True):
            a, b = json.loads(line_a), json.loads(line_b)
            rows.append({"mode": a["mode"], "stage": a["stage"], "sample_count": len(a["samples"]),
                         "identical_json": a == b, "identical_bytes": line_a == line_b})
    if len(rows) != 70:
        raise AssertionError(f"Expected70 mode/stage comparisons, received{len(rows)}")
    return {"comparisons": len(rows), "native_frames_per_target": sum(row["sample_count"] for row in rows),
            "all_json_identical": all(row["identical_json"] for row in rows),
            "all_bytes_identical": all(row["identical_bytes"] for row in rows),
            "source_export_sha256": sha(left), "jar_export_sha256": sha(right), "results": rows}


# REPORT: hash the final JAR and reject files that change during inspection.
def main():
    HERE.mkdir(parents=True, exist_ok=True)
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--jar", type=Path, required=True)
    parser.add_argument("--baseline", type=Path, default=BASELINE)
    parser.add_argument("--source-only", action="store_true",
                        help="Verificar equivalencia fuente/JAR; registrar diferencias de backend sin evaluarlas")
    args = parser.parse_args()
    jar, baseline = args.jar.resolve(), args.baseline.resolve()
    stable = {path: sha(path) for path in (SOURCE, baseline, jar)}
    harness, source_classes = HERE / "harness-classes", HERE / "source-classes"
    harness.mkdir(exist_ok=True)
    source_classes.mkdir(exist_ok=True)
    subprocess.run([str(JAVA_BIN / "javac.exe"), "-encoding", "UTF-8", "-d", str(harness),
                    str(HARNESS)], check=True, capture_output=True, timeout=45)
    subprocess.run([str(JAVA_BIN / "javac.exe"), "-encoding", "UTF-8", "-d", str(source_classes), str(SOURCE)],
                   check=True, capture_output=True, timeout=45)
    source_output, jar_output = HERE / "source.jsonl", HERE / "packaged.jsonl"
    export(str(source_classes) + ";" + str(harness), source_output)
    export(str(jar) + ";" + str(harness), jar_output)
    native = compare_exports(source_output, jar_output)
    zip_audit = compare_zip(baseline, jar)
    unchanged = all(sha(path) == expected for path, expected in stable.items())
    passed = native["all_json_identical"] and unchanged and (args.source_only or zip_audit["backend_and_resources_identical"])
    report = {"passed": passed, "jar": str(jar), "jar_sha256": stable[jar],
              "baseline": str(baseline), "baseline_sha256": stable[baseline],
              "native_source_sha256": stable[SOURCE], "inputs_stable": unchanged,
              "native_cli": native, "zip_comparison": zip_audit,
              "scope": "Packaging/source equivalence only; no server/GameTests executed by this harness",
              "backend_comparison_enforced": not args.source_only}
    destination = HERE / "result.json"
    destination.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({"passed": passed, "jar_sha256": stable[jar], "report_sha256": sha(destination),
                      "native_comparisons": native["comparisons"], "native_frames": native["native_frames_per_target"],
                      "all_json_identical": native["all_json_identical"], "changed_entries": [x["name"] for x in zip_audit["changed_entries"]],
                      "backend_classes_changed": zip_audit["backend_classes_changed"],
                      "resources_changed": zip_audit["resources_changed"]}, indent=2), flush=True)
    if not passed:
        raise SystemExit(1)


if __name__ == "__main__":
    main()