mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-04-13 12:22:33 -05:00
feat: add pack integrity test and integrate into pipeline
Extract each platform ZIP to tmp/ (real filesystem, not /tmp tmpfs) and verify every declared file exists at the correct path with the correct hash per the platform's native verification mode. Handles ZIP inner content verification (checkInsideZip, md5_composite, inner ROM MD5) and path collision deduplication. Integrated as pipeline step 6/8. Renumber all pipeline steps to be sequential (was skipping from 5 to 8).
This commit is contained in:
@@ -99,7 +99,7 @@ def check_consistency(verify_output: str, pack_output: str) -> bool:
|
|||||||
v = parse_verify_counts(verify_output)
|
v = parse_verify_counts(verify_output)
|
||||||
p = parse_pack_counts(pack_output)
|
p = parse_pack_counts(pack_output)
|
||||||
|
|
||||||
print("\n--- 5/9 consistency check ---")
|
print("\n--- 5/8 consistency check ---")
|
||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
for v_label, (v_ok, v_total) in sorted(v.items()):
|
for v_label, (v_ok, v_total) in sorted(v.items()):
|
||||||
@@ -164,7 +164,7 @@ def main():
|
|||||||
ok, out = run(
|
ok, out = run(
|
||||||
[sys.executable, "scripts/generate_db.py", "--force",
|
[sys.executable, "scripts/generate_db.py", "--force",
|
||||||
"--bios-dir", "bios", "--output", "database.json"],
|
"--bios-dir", "bios", "--output", "database.json"],
|
||||||
"1/9 generate database",
|
"1/8 generate database",
|
||||||
)
|
)
|
||||||
results["generate_db"] = ok
|
results["generate_db"] = ok
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -175,11 +175,11 @@ def main():
|
|||||||
if not args.offline:
|
if not args.offline:
|
||||||
ok, out = run(
|
ok, out = run(
|
||||||
[sys.executable, "scripts/refresh_data_dirs.py"],
|
[sys.executable, "scripts/refresh_data_dirs.py"],
|
||||||
"2/9 refresh data directories",
|
"2/8 refresh data directories",
|
||||||
)
|
)
|
||||||
results["refresh_data"] = ok
|
results["refresh_data"] = ok
|
||||||
else:
|
else:
|
||||||
print("\n--- 2/9 refresh data directories: SKIPPED (--offline) ---")
|
print("\n--- 2/8 refresh data directories: SKIPPED (--offline) ---")
|
||||||
results["refresh_data"] = True
|
results["refresh_data"] = True
|
||||||
|
|
||||||
# Step 2a: Refresh MAME BIOS hashes
|
# Step 2a: Refresh MAME BIOS hashes
|
||||||
@@ -259,7 +259,7 @@ def main():
|
|||||||
verify_cmd.append("--include-archived")
|
verify_cmd.append("--include-archived")
|
||||||
if args.target:
|
if args.target:
|
||||||
verify_cmd.extend(["--target", args.target])
|
verify_cmd.extend(["--target", args.target])
|
||||||
ok, verify_output = run(verify_cmd, "3/9 verify all platforms")
|
ok, verify_output = run(verify_cmd, "3/8 verify all platforms")
|
||||||
results["verify"] = ok
|
results["verify"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
|
|
||||||
@@ -278,11 +278,11 @@ def main():
|
|||||||
pack_cmd.append("--include-extras")
|
pack_cmd.append("--include-extras")
|
||||||
if args.target:
|
if args.target:
|
||||||
pack_cmd.extend(["--target", args.target])
|
pack_cmd.extend(["--target", args.target])
|
||||||
ok, pack_output = run(pack_cmd, "4/9 generate packs")
|
ok, pack_output = run(pack_cmd, "4/8 generate packs")
|
||||||
results["generate_packs"] = ok
|
results["generate_packs"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
else:
|
else:
|
||||||
print("\n--- 4/9 generate packs: SKIPPED (--skip-packs) ---")
|
print("\n--- 4/8 generate packs: SKIPPED (--skip-packs) ---")
|
||||||
results["generate_packs"] = True
|
results["generate_packs"] = True
|
||||||
|
|
||||||
# Step 4b: Generate install manifests
|
# Step 4b: Generate install manifests
|
||||||
@@ -297,11 +297,11 @@ def main():
|
|||||||
manifest_cmd.append("--offline")
|
manifest_cmd.append("--offline")
|
||||||
if args.target:
|
if args.target:
|
||||||
manifest_cmd.extend(["--target", args.target])
|
manifest_cmd.extend(["--target", args.target])
|
||||||
ok, _ = run(manifest_cmd, "4b/9 generate install manifests")
|
ok, _ = run(manifest_cmd, "4b/8 generate install manifests")
|
||||||
results["generate_manifests"] = ok
|
results["generate_manifests"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
else:
|
else:
|
||||||
print("\n--- 4b/9 generate install manifests: SKIPPED (--skip-packs) ---")
|
print("\n--- 4b/8 generate install manifests: SKIPPED (--skip-packs) ---")
|
||||||
results["generate_manifests"] = True
|
results["generate_manifests"] = True
|
||||||
|
|
||||||
# Step 4c: Generate target manifests
|
# Step 4c: Generate target manifests
|
||||||
@@ -310,11 +310,11 @@ def main():
|
|||||||
sys.executable, "scripts/generate_pack.py",
|
sys.executable, "scripts/generate_pack.py",
|
||||||
"--manifest-targets", "--output-dir", "install/targets",
|
"--manifest-targets", "--output-dir", "install/targets",
|
||||||
]
|
]
|
||||||
ok, _ = run(target_cmd, "4c/9 generate target manifests")
|
ok, _ = run(target_cmd, "4c/8 generate target manifests")
|
||||||
results["generate_target_manifests"] = ok
|
results["generate_target_manifests"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
else:
|
else:
|
||||||
print("\n--- 4c/9 generate target manifests: SKIPPED (--skip-packs) ---")
|
print("\n--- 4c/8 generate target manifests: SKIPPED (--skip-packs) ---")
|
||||||
results["generate_target_manifests"] = True
|
results["generate_target_manifests"] = True
|
||||||
|
|
||||||
# Step 5: Consistency check
|
# Step 5: Consistency check
|
||||||
@@ -323,32 +323,44 @@ def main():
|
|||||||
results["consistency"] = ok
|
results["consistency"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
else:
|
else:
|
||||||
print("\n--- 5/9 consistency check: SKIPPED ---")
|
print("\n--- 5/8 consistency check: SKIPPED ---")
|
||||||
results["consistency"] = True
|
results["consistency"] = True
|
||||||
|
|
||||||
# Step 8: Generate README
|
# Step 6: Pack integrity (extract + hash verification)
|
||||||
|
if not args.skip_packs:
|
||||||
|
ok, _ = run(
|
||||||
|
[sys.executable, "-m", "unittest", "tests.test_pack_integrity", "-v"],
|
||||||
|
"6/8 pack integrity",
|
||||||
|
)
|
||||||
|
results["pack_integrity"] = ok
|
||||||
|
all_ok = all_ok and ok
|
||||||
|
else:
|
||||||
|
print("\n--- 6/8 pack integrity: SKIPPED (--skip-packs) ---")
|
||||||
|
results["pack_integrity"] = True
|
||||||
|
|
||||||
|
# Step 7: Generate README
|
||||||
if not args.skip_docs:
|
if not args.skip_docs:
|
||||||
ok, _ = run(
|
ok, _ = run(
|
||||||
[sys.executable, "scripts/generate_readme.py",
|
[sys.executable, "scripts/generate_readme.py",
|
||||||
"--db", "database.json", "--platforms-dir", "platforms"],
|
"--db", "database.json", "--platforms-dir", "platforms"],
|
||||||
"8/9 generate readme",
|
"7/8 generate readme",
|
||||||
)
|
)
|
||||||
results["generate_readme"] = ok
|
results["generate_readme"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
else:
|
else:
|
||||||
print("\n--- 8/9 generate readme: SKIPPED (--skip-docs) ---")
|
print("\n--- 7/8 generate readme: SKIPPED (--skip-docs) ---")
|
||||||
results["generate_readme"] = True
|
results["generate_readme"] = True
|
||||||
|
|
||||||
# Step 9: Generate site pages
|
# Step 8: Generate site pages
|
||||||
if not args.skip_docs:
|
if not args.skip_docs:
|
||||||
ok, _ = run(
|
ok, _ = run(
|
||||||
[sys.executable, "scripts/generate_site.py"],
|
[sys.executable, "scripts/generate_site.py"],
|
||||||
"9/9 generate site",
|
"8/8 generate site",
|
||||||
)
|
)
|
||||||
results["generate_site"] = ok
|
results["generate_site"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
else:
|
else:
|
||||||
print("\n--- 9/9 generate site: SKIPPED (--skip-docs) ---")
|
print("\n--- 8/8 generate site: SKIPPED (--skip-docs) ---")
|
||||||
results["generate_site"] = True
|
results["generate_site"] = True
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
|
|||||||
264
tests/test_pack_integrity.py
Normal file
264
tests/test_pack_integrity.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""End-to-end pack integrity test.
|
||||||
|
|
||||||
|
Extracts each platform ZIP pack to tmp/ (in the repo, not /tmp which
|
||||||
|
is tmpfs on WSL) and verifies that:
|
||||||
|
1. The archive is not corrupt and fully decompressable
|
||||||
|
2. Every file declared in the platform YAML exists at the correct path
|
||||||
|
3. Every extracted file has the correct hash per the platform's native
|
||||||
|
verification mode
|
||||||
|
|
||||||
|
This closes the loop: verify.py checks source bios/ -> this script
|
||||||
|
checks the final delivered ZIP the user actually downloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||||
|
from common import check_inside_zip, load_platform_config, md5_composite
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||||
|
DIST_DIR = os.path.join(REPO_ROOT, "dist")
|
||||||
|
PLATFORMS_DIR = os.path.join(REPO_ROOT, "platforms")
|
||||||
|
TMP_DIR = os.path.join(REPO_ROOT, "tmp", "pack_test")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_zip(platform_name: str) -> str | None:
|
||||||
|
"""Find the ZIP pack for a platform in dist/."""
|
||||||
|
if not os.path.isdir(DIST_DIR):
|
||||||
|
return None
|
||||||
|
config = load_platform_config(platform_name, PLATFORMS_DIR)
|
||||||
|
display = config.get("platform", platform_name).replace(" ", "_")
|
||||||
|
for f in os.listdir(DIST_DIR):
|
||||||
|
if f.endswith("_BIOS_Pack.zip") and display in f:
|
||||||
|
return os.path.join(DIST_DIR, f)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_file(path: str, algo: str) -> str:
|
||||||
|
"""Compute hash of a file on disk."""
|
||||||
|
h = hashlib.new(algo)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class PackIntegrityTest(unittest.TestCase):
|
||||||
|
"""Verify each platform pack delivers files at correct paths with correct hashes."""
|
||||||
|
|
||||||
|
def _verify_platform(self, platform_name: str) -> None:
|
||||||
|
zip_path = _find_zip(platform_name)
|
||||||
|
if not zip_path or not os.path.exists(zip_path):
|
||||||
|
self.skipTest(f"no pack found for {platform_name}")
|
||||||
|
|
||||||
|
config = load_platform_config(platform_name, PLATFORMS_DIR)
|
||||||
|
base_dest = config.get("base_destination", "")
|
||||||
|
mode = config.get("verification_mode", "existence")
|
||||||
|
systems = config.get("systems", {})
|
||||||
|
|
||||||
|
extract_dir = os.path.join(TMP_DIR, platform_name)
|
||||||
|
os.makedirs(extract_dir, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Phase 1: extract — proves the archive is not corrupt
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
# Phase 2: verify every declared file
|
||||||
|
missing = []
|
||||||
|
hash_fail = []
|
||||||
|
ok = 0
|
||||||
|
|
||||||
|
for sys_id, sys_data in systems.items():
|
||||||
|
for fe in sys_data.get("files", []):
|
||||||
|
dest = fe.get("destination", fe.get("name", ""))
|
||||||
|
if not dest:
|
||||||
|
continue # EmuDeck hash-only entries
|
||||||
|
|
||||||
|
if base_dest:
|
||||||
|
file_path = os.path.join(extract_dir, base_dest, dest)
|
||||||
|
else:
|
||||||
|
file_path = os.path.join(extract_dir, dest)
|
||||||
|
|
||||||
|
# Case-insensitive fallback
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
parent = os.path.dirname(file_path)
|
||||||
|
basename = os.path.basename(file_path)
|
||||||
|
if os.path.isdir(parent):
|
||||||
|
for entry in os.listdir(parent):
|
||||||
|
if entry.lower() == basename.lower():
|
||||||
|
file_path = os.path.join(parent, entry)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
missing.append(f"{sys_id}: {dest}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Existence mode: file present on disk = pass
|
||||||
|
if mode == "existence":
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# SHA1 mode (BizHawk)
|
||||||
|
if mode == "sha1":
|
||||||
|
expected_hash = fe.get("sha1", "")
|
||||||
|
if not expected_hash:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
actual = _hash_file(file_path, "sha1")
|
||||||
|
if actual != expected_hash.lower():
|
||||||
|
hash_fail.append(
|
||||||
|
f"{sys_id}: {dest} sha1 "
|
||||||
|
f"expected={expected_hash} got={actual}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# MD5 mode
|
||||||
|
expected_md5 = fe.get("md5", "")
|
||||||
|
if not expected_md5:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
md5_list = [
|
||||||
|
m.strip().lower()
|
||||||
|
for m in expected_md5.split(",")
|
||||||
|
if m.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Regular MD5 (file on disk)
|
||||||
|
actual_md5 = _hash_file(file_path, "md5")
|
||||||
|
if actual_md5 in md5_list:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Truncated MD5 (Batocera 29-char bug)
|
||||||
|
if any(
|
||||||
|
actual_md5.startswith(m)
|
||||||
|
for m in md5_list
|
||||||
|
if len(m) < 32
|
||||||
|
):
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For .zip files, the YAML MD5 refers to inner
|
||||||
|
# content, not the container. The pack rebuilds
|
||||||
|
# ZIPs deterministically so the container hash
|
||||||
|
# differs from upstream.
|
||||||
|
if file_path.endswith(".zip"):
|
||||||
|
# 1. checkInsideZip (Batocera)
|
||||||
|
zipped_file = fe.get("zipped_file")
|
||||||
|
if zipped_file:
|
||||||
|
try:
|
||||||
|
inner = check_inside_zip(file_path, zipped_file)
|
||||||
|
if inner and inner.lower() in md5_list:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. md5_composite (Recalbox)
|
||||||
|
try:
|
||||||
|
composite = md5_composite(file_path)
|
||||||
|
if composite and composite.lower() in md5_list:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Any inner file MD5 (MAME ROM sets)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(file_path) as izf:
|
||||||
|
for iname in izf.namelist():
|
||||||
|
imd5 = hashlib.md5(
|
||||||
|
izf.read(iname)
|
||||||
|
).hexdigest()
|
||||||
|
if imd5 in md5_list:
|
||||||
|
ok += 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
ok += 1 # inner content verified by verify.py
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Path collision: same filename, different systems
|
||||||
|
dedup_key = os.path.basename(dest)
|
||||||
|
collision = sum(
|
||||||
|
1 for sd in systems.values()
|
||||||
|
for ff in sd.get("files", [])
|
||||||
|
if os.path.basename(
|
||||||
|
ff.get("destination", ff.get("name", "")) or ""
|
||||||
|
) == dedup_key
|
||||||
|
) > 1
|
||||||
|
|
||||||
|
if collision:
|
||||||
|
ok += 1 # dedup chose another variant
|
||||||
|
else:
|
||||||
|
hash_fail.append(
|
||||||
|
f"{sys_id}: {dest} md5 "
|
||||||
|
f"expected={md5_list} got={actual_md5}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Report
|
||||||
|
total_declared = sum(
|
||||||
|
len([
|
||||||
|
f for f in s.get("files", [])
|
||||||
|
if f.get("destination", f.get("name", ""))
|
||||||
|
])
|
||||||
|
for s in systems.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
self.fail(
|
||||||
|
f"{platform_name}: {len(missing)}/{total_declared} "
|
||||||
|
f"files missing:\n"
|
||||||
|
+ "\n".join(f" {m}" for m in missing[:20])
|
||||||
|
)
|
||||||
|
if hash_fail:
|
||||||
|
self.fail(
|
||||||
|
f"{platform_name}: {len(hash_fail)} hash mismatches:\n"
|
||||||
|
+ "\n".join(f" {h}" for h in hash_fail[:20])
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up extracted files
|
||||||
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_retroarch(self):
|
||||||
|
self._verify_platform("retroarch")
|
||||||
|
|
||||||
|
def test_batocera(self):
|
||||||
|
self._verify_platform("batocera")
|
||||||
|
|
||||||
|
def test_bizhawk(self):
|
||||||
|
self._verify_platform("bizhawk")
|
||||||
|
|
||||||
|
def test_emudeck(self):
|
||||||
|
self._verify_platform("emudeck")
|
||||||
|
|
||||||
|
def test_recalbox(self):
|
||||||
|
self._verify_platform("recalbox")
|
||||||
|
|
||||||
|
def test_retrobat(self):
|
||||||
|
self._verify_platform("retrobat")
|
||||||
|
|
||||||
|
def test_retrodeck(self):
|
||||||
|
self._verify_platform("retrodeck")
|
||||||
|
|
||||||
|
def test_romm(self):
|
||||||
|
self._verify_platform("romm")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user