diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index bdfd9b69..606de817 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -75,6 +75,7 @@ from nativemode import ( ) from validation import ( agnostic_substitute, + frontend_digest_matches, _build_validation_index, check_file_validation, filter_files_by_mode, @@ -885,6 +886,11 @@ def generate_pack( ) if not zip_ok: continue + elif frontend_digest_matches( + file_entry, local_path, digest_algorithm(verification_mode) + ): + status = "frontend_digest_exact" + file_status.setdefault(dedup_key, "ok") else: file_status[dedup_key] = "excluded" file_reasons[dedup_key] = "hash mismatch" @@ -3108,6 +3114,16 @@ def generate_manifest( and _inner_rom_check(file_entry, local_path) == "ok" ): status = "zip_exact" + if ( + status == "hash_mismatch" + and local_path + and not file_entry.get("zipped_file") + and hash_mismatch_excludes_file(verification_mode) + and frontend_digest_matches( + file_entry, local_path, digest_algorithm(verification_mode) + ) + ): + status = "frontend_digest_exact" # An existence platform never reads the bytes, so a declared # hash the local dump contradicts is not a reason to withhold # the file. Hash platforms would reject it, so they omit it. diff --git a/scripts/packverify.py b/scripts/packverify.py index 1df6199e..35170763 100644 --- a/scripts/packverify.py +++ b/scripts/packverify.py @@ -17,6 +17,7 @@ from packpaths import _register_path from ziptools import build_zip_contents_index from ziptools import check_inside_zip from nativemode import digest_algorithm +from validation import frontend_digest_matches from nativemode import hash_mismatch_excludes_file import hashlib from common import filter_systems_by_target @@ -406,6 +407,11 @@ def _intentional_hash_exclusion( ) if status != "hash_mismatch": return False + if not entry.get("zipped_file") and frontend_digest_matches( + entry, local_path, digest_algorithm(verification_mode) + ): + # The frontend's own digest accepts it: the builder ships it. + return False # A container can mismatch the outer declaration while still carrying # the exact inner ROM requested by Batocera-style zipped_file entries. diff --git a/scripts/validation.py b/scripts/validation.py index 5b568294..0a710cbc 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -431,3 +431,22 @@ def agnostic_substitute( return held, prefix break return None + + +def frontend_digest_matches(file_entry: dict, local_path: str, algorithm: str) -> bool: + """Whether the frontend's own digest accepts the file. + + resolve_local_file calls a file `hash_mismatch` when any declared hash + disagrees, but a frontend compares one digest. RomM's list carries md5, + sha1 and crc32: a mistyped crc32 next to the right md5 must not withhold + a file RomM accepts. Same comparison as verify_entry_md5/_sha1. + """ + if algorithm == "md5": + declared = parse_md5_list(file_entry.get("md5")) + else: + value = file_entry.get(algorithm) + values = value if isinstance(value, list) else [value] + declared = [str(v).strip().lower() for v in values if v] + if not declared or not local_path: + return False + return compute_hashes(local_path)[algorithm].lower() in declared diff --git a/tests/test_frontend_digest.py b/tests/test_frontend_digest.py new file mode 100644 index 00000000..693ee567 --- /dev/null +++ b/tests/test_frontend_digest.py @@ -0,0 +1,89 @@ +"""A digest frontend withholds a file only when ITS digest disagrees. + +RomM's list declares md5, sha1 and crc32 per file and RomM accepts any of +them. The resolver calls a file `hash_mismatch` when any declared hash +disagrees, so a mistyped crc32 beside the right md5 dropped the file from +the pack and the manifest while verify, comparing md5, called it OK. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + + +class FrontendDigestDecides(unittest.TestCase): + def setUp(self): + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.tmp = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.bios = root / "bios" + self.bios.mkdir() + payload = b"psx bios " * 64 + held = self.bios / "scph5501.bin" + held.write_bytes(payload) + md5 = hashlib.md5(payload).hexdigest() + sha1 = hashlib.sha1(payload).hexdigest() + self.platforms = root / "platforms" + self.platforms.mkdir() + (root / "emulators").mkdir() + self.emulators = root / "emulators" + (self.platforms / "_registry.yml").write_text( + yaml.safe_dump({"platforms": {"digestplat": {"status": "active"}}}) + ) + (self.platforms / "digestplat.yml").write_text(yaml.safe_dump({ + "platform": "DigestPlat", + "verification_mode": "md5", + "base_destination": "bios", + "systems": {"psx": {"files": [{ + "name": "scph5501.bin", "destination": "scph5501.bin", + "md5": md5, "crc32": "00000000", "required": True, + }]}}, + })) + self.db = { + "files": {sha1: {"path": str(held), "name": "scph5501.bin", "size": len(payload), + "sha1": sha1, "md5": md5, "crc32": "deadbeef"}}, + "indexes": {"by_md5": {md5: sha1}, "by_name": {"scph5501.bin": [sha1]}, + "by_crc32": {}, "by_path_suffix": {}}, + } + self.root = root + + def test_pack_manifest_and_verify_agree(self): + from common import load_platform_config + from generate_pack import generate_manifest, generate_pack + from verify import verify_platform + + os.chdir(self.root) + self.addCleanup(os.chdir, REPO_ROOT) + zip_path = generate_pack( + "digestplat", str(self.platforms), self.db, str(self.bios), + str(self.root / "out"), emulators_dir=str(self.emulators), + emu_profiles={}, offline=True, + ) + with zipfile.ZipFile(zip_path) as zf: + self.assertIn("scph5501.bin", {Path(n).name for n in zf.namelist()}) + manifest = generate_manifest( + "digestplat", str(self.platforms), self.db, str(self.bios), + str(self.platforms / "_registry.yml"), emulators_dir=str(self.emulators), + emu_profiles={}, offline=True, + ) + self.assertEqual([f["dest"] for f in manifest["files"]], ["scph5501.bin"]) + config = load_platform_config("digestplat", str(self.platforms)) + result = verify_platform(config, self.db, str(self.emulators), emu_profiles={}) + self.assertEqual(result["details"][0]["status"], "ok") + + +if __name__ == "__main__": + unittest.main()