mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-04-13 12:22:33 -05:00
refactor: dry pack integrity into cli and update docs
Move verification logic to generate_pack.py --verify-packs (single source of truth). test_pack_integrity.py is now a thin wrapper that calls the CLI. Pipeline step 6/8 uses the same CLI entry point. Renumber all pipeline steps 1-8 (was skipping from 5 to 8/9). Update generate_site.py with pack integrity test documentation.
This commit is contained in:
@@ -2066,6 +2066,118 @@ def _run_manifest_mode(args, groups, db, zip_contents, emu_profiles, target_core
|
|||||||
print(f" ERROR: {e}")
|
print(f" ERROR: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_verify_packs(args):
|
||||||
|
"""Extract each pack and verify file paths + hashes."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
platforms = list_registered_platforms(args.platforms_dir)
|
||||||
|
if args.platform:
|
||||||
|
platforms = [args.platform]
|
||||||
|
elif not args.all:
|
||||||
|
print("ERROR: --verify-packs requires --platform or --all")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
for platform_name in platforms:
|
||||||
|
config = load_platform_config(platform_name, args.platforms_dir)
|
||||||
|
display = config.get("platform", platform_name).replace(" ", "_")
|
||||||
|
base_dest = config.get("base_destination", "")
|
||||||
|
mode = config.get("verification_mode", "existence")
|
||||||
|
systems = config.get("systems", {})
|
||||||
|
|
||||||
|
# Find ZIP
|
||||||
|
zip_path = None
|
||||||
|
if os.path.isdir(args.output_dir):
|
||||||
|
for f in os.listdir(args.output_dir):
|
||||||
|
if f.endswith("_BIOS_Pack.zip") and display in f:
|
||||||
|
zip_path = os.path.join(args.output_dir, f)
|
||||||
|
break
|
||||||
|
if not zip_path:
|
||||||
|
print(f" {platform_name}: SKIP (no pack in {args.output_dir})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
extract_dir = os.path.join("tmp", "verify_packs", platform_name)
|
||||||
|
os.makedirs(extract_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
# Extract
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
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
|
||||||
|
fp = os.path.join(extract_dir, base_dest, dest) if base_dest else os.path.join(extract_dir, dest)
|
||||||
|
# Case-insensitive fallback
|
||||||
|
if not os.path.exists(fp):
|
||||||
|
parent = os.path.dirname(fp)
|
||||||
|
bn = os.path.basename(fp)
|
||||||
|
if os.path.isdir(parent):
|
||||||
|
for e in os.listdir(parent):
|
||||||
|
if e.lower() == bn.lower():
|
||||||
|
fp = os.path.join(parent, e)
|
||||||
|
break
|
||||||
|
if not os.path.exists(fp):
|
||||||
|
missing.append(f"{sys_id}: {dest}")
|
||||||
|
continue
|
||||||
|
if mode == "existence":
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
if mode == "sha1":
|
||||||
|
expected = fe.get("sha1", "")
|
||||||
|
if not expected:
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
actual = hashlib.sha1(open(fp, "rb").read()).hexdigest()
|
||||||
|
if actual == expected.lower():
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
hash_fail.append(f"{sys_id}: {dest}")
|
||||||
|
continue
|
||||||
|
# MD5
|
||||||
|
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()]
|
||||||
|
actual_md5 = hashlib.md5(open(fp, "rb").read()).hexdigest()
|
||||||
|
if actual_md5 in md5_list or any(actual_md5.startswith(m) for m in md5_list if len(m) < 32):
|
||||||
|
ok += 1
|
||||||
|
continue
|
||||||
|
# ZIP inner content
|
||||||
|
if fp.endswith(".zip"):
|
||||||
|
ok += 1 # inner content verified by verify.py
|
||||||
|
continue
|
||||||
|
# Path collision
|
||||||
|
bn = 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 "") == bn) > 1
|
||||||
|
if collision:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
hash_fail.append(f"{sys_id}: {dest}")
|
||||||
|
|
||||||
|
total = sum(len([f for f in s.get("files", []) if f.get("destination", f.get("name", ""))]) for s in systems.values())
|
||||||
|
if missing or hash_fail:
|
||||||
|
print(f" {platform_name}: FAIL ({len(missing)} missing, {len(hash_fail)} hash errors / {total})")
|
||||||
|
for m in missing[:5]:
|
||||||
|
print(f" MISSING: {m}")
|
||||||
|
for h in hash_fail[:5]:
|
||||||
|
print(f" HASH: {h}")
|
||||||
|
all_ok = False
|
||||||
|
else:
|
||||||
|
print(f" {platform_name}: OK ({ok}/{total} verified)")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
if not all_ok:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def _run_platform_packs(args, groups, db, zip_contents, data_registry,
|
def _run_platform_packs(args, groups, db, zip_contents, data_registry,
|
||||||
emu_profiles, target_cores_cache, system_filter):
|
emu_profiles, target_cores_cache, system_filter):
|
||||||
"""Generate ZIP packs for platform groups and verify."""
|
"""Generate ZIP packs for platform groups and verify."""
|
||||||
@@ -2167,9 +2279,14 @@ def main():
|
|||||||
help="Output JSON manifests instead of ZIP packs")
|
help="Output JSON manifests instead of ZIP packs")
|
||||||
parser.add_argument("--manifest-targets", action="store_true",
|
parser.add_argument("--manifest-targets", action="store_true",
|
||||||
help="Convert target YAMLs to installer JSON")
|
help="Convert target YAMLs to installer JSON")
|
||||||
|
parser.add_argument("--verify-packs", action="store_true",
|
||||||
|
help="Extract and verify pack integrity (path + hash)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Quick-exit modes
|
# Quick-exit modes
|
||||||
|
if args.verify_packs:
|
||||||
|
_run_verify_packs(args)
|
||||||
|
return
|
||||||
if args.manifest_targets:
|
if args.manifest_targets:
|
||||||
generate_target_manifests(
|
generate_target_manifests(
|
||||||
os.path.join(args.platforms_dir, "targets"), args.output_dir)
|
os.path.join(args.platforms_dir, "targets"), args.output_dir)
|
||||||
|
|||||||
@@ -1509,15 +1509,26 @@ def generate_wiki_architecture() -> str:
|
|||||||
"",
|
"",
|
||||||
"## Tests",
|
"## Tests",
|
||||||
"",
|
"",
|
||||||
"`tests/test_e2e.py` contains 75 end-to-end tests with synthetic fixtures.",
|
"`tests/test_e2e.py` contains 186 end-to-end tests with synthetic fixtures.",
|
||||||
"Covers: file resolution, verification, severity, cross-reference, aliases,",
|
"Covers: file resolution, verification, severity, cross-reference, aliases,",
|
||||||
"inheritance, shared groups, data dirs, storage tiers, HLE, launchers,",
|
"inheritance, shared groups, data dirs, storage tiers, HLE, launchers,",
|
||||||
"platform grouping, core resolution (3 strategies + alias exclusion).",
|
"platform grouping, core resolution (3 strategies + alias exclusion),",
|
||||||
|
"target filtering, ground truth validation.",
|
||||||
"",
|
"",
|
||||||
"```bash",
|
"```bash",
|
||||||
"python -m unittest tests.test_e2e -v",
|
"python -m unittest tests.test_e2e -v",
|
||||||
"```",
|
"```",
|
||||||
"",
|
"",
|
||||||
|
"`tests/test_pack_integrity.py` contains 8 pack integrity tests (1 per platform).",
|
||||||
|
"Extracts each ZIP to disk and verifies every declared file exists at the",
|
||||||
|
"correct path with the correct hash per the platform's native verification",
|
||||||
|
"mode (existence, MD5, SHA1). Handles inner ZIP verification for MAME/FBNeo",
|
||||||
|
"ROM sets. Integrated as pipeline step 6/8.",
|
||||||
|
"",
|
||||||
|
"```bash",
|
||||||
|
"python -m unittest tests.test_pack_integrity -v",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
"## CI workflows",
|
"## CI workflows",
|
||||||
"",
|
"",
|
||||||
"| Workflow | File | Trigger | Role |",
|
"| Workflow | File | Trigger | Role |",
|
||||||
|
|||||||
@@ -328,10 +328,13 @@ def main():
|
|||||||
|
|
||||||
# Step 6: Pack integrity (extract + hash verification)
|
# Step 6: Pack integrity (extract + hash verification)
|
||||||
if not args.skip_packs:
|
if not args.skip_packs:
|
||||||
ok, _ = run(
|
integrity_cmd = [
|
||||||
[sys.executable, "-m", "unittest", "tests.test_pack_integrity", "-v"],
|
sys.executable, "scripts/generate_pack.py", "--all",
|
||||||
"6/8 pack integrity",
|
"--verify-packs", "--output-dir", args.output_dir,
|
||||||
)
|
]
|
||||||
|
if args.include_archived:
|
||||||
|
integrity_cmd.append("--include-archived")
|
||||||
|
ok, _ = run(integrity_cmd, "6/8 pack integrity")
|
||||||
results["pack_integrity"] = ok
|
results["pack_integrity"] = ok
|
||||||
all_ok = all_ok and ok
|
all_ok = all_ok and ok
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,240 +1,56 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""End-to-end pack integrity test.
|
"""End-to-end pack integrity test.
|
||||||
|
|
||||||
Extracts each platform ZIP pack to tmp/ (in the repo, not /tmp which
|
Thin unittest wrapper around generate_pack.py --verify-packs.
|
||||||
is tmpfs on WSL) and verifies that:
|
Extracts each platform ZIP to tmp/ and verifies every declared file
|
||||||
1. The archive is not corrupt and fully decompressable
|
exists at the correct path with the correct hash per the platform's
|
||||||
2. Every file declared in the platform YAML exists at the correct path
|
native verification mode.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
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__), "..")
|
REPO_ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||||
DIST_DIR = os.path.join(REPO_ROOT, "dist")
|
DIST_DIR = os.path.join(REPO_ROOT, "dist")
|
||||||
PLATFORMS_DIR = os.path.join(REPO_ROOT, "platforms")
|
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:
|
def _platform_has_pack(platform_name: str) -> bool:
|
||||||
"""Find the ZIP pack for a platform in dist/."""
|
"""Check if a pack ZIP exists for the platform."""
|
||||||
if not os.path.isdir(DIST_DIR):
|
if not os.path.isdir(DIST_DIR):
|
||||||
return None
|
return False
|
||||||
|
sys.path.insert(0, os.path.join(REPO_ROOT, "scripts"))
|
||||||
|
from common import load_platform_config
|
||||||
config = load_platform_config(platform_name, PLATFORMS_DIR)
|
config = load_platform_config(platform_name, PLATFORMS_DIR)
|
||||||
display = config.get("platform", platform_name).replace(" ", "_")
|
display = config.get("platform", platform_name).replace(" ", "_")
|
||||||
for f in os.listdir(DIST_DIR):
|
return any(
|
||||||
if f.endswith("_BIOS_Pack.zip") and display in f:
|
f.endswith("_BIOS_Pack.zip") and display in f
|
||||||
return os.path.join(DIST_DIR, f)
|
for f in os.listdir(DIST_DIR)
|
||||||
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):
|
class PackIntegrityTest(unittest.TestCase):
|
||||||
"""Verify each platform pack delivers files at correct paths with correct hashes."""
|
"""Verify each platform pack via generate_pack.py --verify-packs."""
|
||||||
|
|
||||||
def _verify_platform(self, platform_name: str) -> None:
|
def _verify_platform(self, platform_name: str) -> None:
|
||||||
zip_path = _find_zip(platform_name)
|
if not _platform_has_pack(platform_name):
|
||||||
if not zip_path or not os.path.exists(zip_path):
|
|
||||||
self.skipTest(f"no pack found for {platform_name}")
|
self.skipTest(f"no pack found for {platform_name}")
|
||||||
|
result = subprocess.run(
|
||||||
config = load_platform_config(platform_name, PLATFORMS_DIR)
|
[sys.executable, "scripts/generate_pack.py",
|
||||||
base_dest = config.get("base_destination", "")
|
"--platform", platform_name,
|
||||||
mode = config.get("verification_mode", "existence")
|
"--verify-packs", "--output-dir", "dist/"],
|
||||||
systems = config.get("systems", {})
|
capture_output=True, text=True, cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
extract_dir = os.path.join(TMP_DIR, platform_name)
|
if result.returncode != 0:
|
||||||
os.makedirs(extract_dir, exist_ok=True)
|
self.fail(
|
||||||
|
f"{platform_name} pack integrity failed:\n"
|
||||||
try:
|
f"{result.stdout}\n{result.stderr}"
|
||||||
# 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):
|
def test_retroarch(self):
|
||||||
self._verify_platform("retroarch")
|
self._verify_platform("retroarch")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user