diff --git a/scripts/validation.py b/scripts/validation.py index 27ec20f7..2f35ae9d 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -8,6 +8,7 @@ truth data for reporting. from __future__ import annotations import os +import zipfile from collections.abc import Mapping from common import ( @@ -17,7 +18,7 @@ from common import ( sanitize_pack_path, size_fits, ) -from hashing import parse_md5_list +from hashing import md5_composite, parse_md5_list from nativemode import digest_algorithm, hash_mismatch_excludes_file from ziptools import check_inside_zip @@ -663,7 +664,8 @@ def frontend_digest_matches(file_entry: dict, local_path: str, algorithm: str) - 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. + a file RomM accepts. verify_entry_md5 reads it too: Batocera compares a + truncated md5 by prefix and Recalbox hashes a ZIP by its members. """ if algorithm == "md5": declared = parse_md5_list(file_entry.get("md5")) @@ -673,7 +675,17 @@ def frontend_digest_matches(file_entry: dict, local_path: str, algorithm: str) - 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 + actual = compute_hashes(local_path)[algorithm].lower() + if algorithm != "md5": + return actual in declared + if any(actual == d or (len(d) < 32 and actual.startswith(d)) for d in declared): + return True + if ".zip" not in os.path.basename(local_path): + return False + try: + return md5_composite(local_path).lower() in declared + except (zipfile.BadZipFile, OSError): + return False def inner_rom_check(file_entry: dict, local_path: str) -> str: diff --git a/scripts/verify.py b/scripts/verify.py index 135f8af7..8f5a7e23 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -28,7 +28,6 @@ import argparse import json import os import sys -import zipfile from collections.abc import Mapping import slots @@ -47,7 +46,6 @@ from common import ( check_inside_zip, compute_hashes, expand_directory_entries, - name_match_size_ok, platform_declarations, filter_systems_by_target, group_identical_platforms, @@ -56,7 +54,6 @@ from common import ( load_data_dir_registry, load_emulator_profiles, load_platform_config, - md5_composite, md5sum, require_yaml, resolve_local_file, @@ -76,6 +73,7 @@ from nativemode import ( ) from validation import ( declaration_settles, + frontend_digest_matches, existence_discrepancy, destination_owners, validated_choice, @@ -235,23 +233,10 @@ def verify_entry_md5( if resolve_status == "md5_exact": return {**base, "status": Status.OK, "path": local_path} + # The builder's comparison, so the two count the same files as held. + if frontend_digest_matches(file_entry, local_path, "md5"): + return {**base, "status": Status.OK, "path": local_path} actual_md5 = md5sum(local_path) - actual_lower = actual_md5.lower() - for expected in md5_list: - if actual_lower == expected.lower(): - return {**base, "status": Status.OK, "path": local_path} - if len(expected) < 32 and actual_lower.startswith(expected.lower()): - return {**base, "status": Status.OK, "path": local_path} - - if ".zip" in os.path.basename(local_path): - try: - composite = md5_composite(local_path) - for expected in md5_list: - if composite.lower() == expected.lower(): - return {**base, "status": Status.OK, "path": local_path} - except (zipfile.BadZipFile, OSError): - pass - return { **base, "status": Status.UNTESTED, diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 09832941..28e36020 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -758,6 +758,32 @@ class OneBaselineVerdict(unittest.TestCase): _intentional_hash_exclusion([entry], db, verification_mode="existence") ) + def test_the_builder_accepts_what_verify_accepts(self): + """verify took a 29-character md5 by prefix and a ZIP by its + composite; the builder compared exact md5s and refused both when + another declared hash disagreed.""" + import hashlib + + from scripts.common import md5_composite + from scripts.validation import baseline_verdict + + with tempfile.TemporaryDirectory(dir=TMP_ROOT) as directory: + loose = Path(directory) / "abc.bin" + loose.write_bytes(b"abc") + truncated = {"name": "abc.bin", "md5": hashlib.md5(b"abc").hexdigest()[:29], + "crc32": "deadbeef"} + self.assertEqual( + baseline_verdict(truncated, str(loose), "hash_mismatch", "md5"), + ("frontend_digest_exact", None), + ) + archive = self._archive(Path(directory), "rom.bin") + composite = {"name": "dragon32.zip", "md5": md5_composite(archive), + "crc32": "deadbeef"} + self.assertEqual( + baseline_verdict(composite, archive, "hash_mismatch", "md5"), + ("frontend_digest_exact", None), + ) + def test_a_release_asset_keeps_its_answer(self): from scripts.slots import apply_override