diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 6cfaddd6d..64c026d85 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -98,6 +98,7 @@ from validation import ( validated_choice, agnostic_substitute, _build_validation_index, + _parse_validation, deliverable, with_archive_members, extra_alternatives, @@ -623,6 +624,10 @@ def _matches_an_alternative(fe: dict, local_path: str, profiles: dict) -> bool: siblings = extra_alternatives(fe, profiles) if len(siblings) < 2: return False + # A sibling the code takes without a check (Raze's "BLOOD: Unknown + # Version") accepts whatever copy fills the slot. + if any(not _parse_validation(entry.get("validation")) for entry in siblings): + return True actual = compute_hashes(local_path, frozenset({"sha1", "md5", "crc32"})) for entry in siblings: for algorithm in ("sha1", "md5", "crc32"): diff --git a/scripts/validation.py b/scripts/validation.py index 300e36d05..544451f90 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -8,6 +8,7 @@ truth data for reporting. from __future__ import annotations import os +import re import zipfile from collections.abc import Callable, Iterator, Mapping, Sequence @@ -161,6 +162,11 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: Raises ValueError if two profiles declare conflicting values. """ index: dict[str, dict] = {} + # A slot where one version is taken without any check (Raze's "BLOOD: + # Unknown Version", which holds the shareware RFF) accepts every copy for + # that core: its validated siblings are alternatives, not requirements. + unchecked: set[tuple[str, str, str]] = set() + checked: set[tuple[str, str, str]] = set() for emu_name, profile in profiles.items(): if profile.get("type") in ("launcher", "alias"): continue @@ -169,8 +175,11 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: if not fname: continue checks = _parse_validation(f.get("validation")) + slot = (fname, emu_name, f.get("path") or fname) if not checks: + unchecked.add(slot) continue + checked.add(slot) if fname not in index: index[fname] = { "checks": set(), @@ -276,6 +285,8 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: existing["source_ref"] = pe_entry["source_ref"] else: pe[emu_name] = pe_entry + for fname, emu_name, _path in sorted(checked & unchecked): + index[fname]["rules"].append(_entry_rule(emu_name, {}, set())) # Convert sets to sorted tuples/lists for determinism for v in index.values(): v["checks"] = sorted(v["checks"]) @@ -324,6 +335,23 @@ def _emulators_for_check( return sorted(result) +_REASON = re.compile(r"^(\w+ mismatch): got (\S+), accepted \[(.*)\]$") + + +def _merged_reason(reasons: list[str]) -> str: + """One reason for every entry of a core that turned the copy away. + + Raze lists three BLOOD.RFF sizes and the message named the first one + alone. Reasons of one kind are merged into a single accepted list. + """ + parsed = [_REASON.match(reason) for reason in reasons] + if all(parsed) and len({(m.group(1), m.group(2)) for m in parsed}) == 1: + accepted = sorted({v for m in parsed for v in m.group(3).split(",") if v}, + key=lambda v: (len(v), v)) + return f"{parsed[0].group(1)}: got {parsed[0].group(2)}, accepted [{','.join(accepted)}]" + return reasons[0] + + def check_file_validation( local_path: str, filename: str, @@ -363,7 +391,7 @@ def check_file_validation( for rule in rules ] if all(reasons): - failures[emu] = reasons[0] + failures[emu] = _merged_reason(reasons) if not failures: return None first = sorted(failures)[0] diff --git a/tests/test_validated_choice.py b/tests/test_validated_choice.py index ca7198b3c..f9aba6707 100644 --- a/tests/test_validated_choice.py +++ b/tests/test_validated_choice.py @@ -325,5 +325,55 @@ class AlternativesAreLookedUpBySlot(unittest.TestCase): self.assertEqual(built.call_count, 2) +class AVersionTakenWithoutACheck(unittest.TestCase): + """The Batocera pack printed two DISCREPANCY lines for BLOOD.RFF that + verify never showed: Raze lists three sized versions and a fourth, + "Unknown Version", it takes without a check (the shareware RFF), and + the message named one accepted size of the three.""" + + RAZE = {"files": [ + {"name": "BLOOD.RFF", "path": "BLOOD.RFF", "size": 8200353, + "crc32": "b291418f", "validation": ["size", "crc32"]}, + {"name": "BLOOD.RFF", "path": "BLOOD.RFF", "size": 9570681, + "crc32": "a8fdda84", "validation": ["size", "crc32"]}, + {"name": "BLOOD.RFF", "path": "BLOOD.RFF"}, + ]} + + def _file(self, tmp: str) -> str: + path = Path(tmp) / "BLOOD.RFF" + path.write_bytes(b"shareware") + return str(path) + + def test_the_unchecked_version_accepts_the_copy(self): + from validation import check_file_validation # noqa: PLC0415 + + index = _build_validation_index({"raze": self.RAZE}) + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + self.assertIsNone(check_file_validation( + self._file(tmp), "BLOOD.RFF", index, emulators={"raze"} + )) + + def test_the_pack_reads_it_as_an_alternative(self): + import generate_pack # noqa: PLC0415 + + fe = {"name": "BLOOD.RFF", "source_profile": "raze", "source_path": "BLOOD.RFF"} + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + self.assertTrue(generate_pack._matches_an_alternative( + fe, self._file(tmp), {"raze": self.RAZE} + )) + + def test_a_refusal_names_every_accepted_size(self): + from validation import check_file_validation # noqa: PLC0415 + + checked = {"files": self.RAZE["files"][:2]} + index = _build_validation_index({"raze": checked}) + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + reason, emulators = check_file_validation( + self._file(tmp), "BLOOD.RFF", index, emulators={"raze"} + ) + self.assertEqual(emulators, ["raze"]) + self.assertEqual(reason, "size mismatch: got 9, accepted [8200353,9570681]") + + if __name__ == "__main__": unittest.main()