diff --git a/scripts/verify.py b/scripts/verify.py index 4373f7da..53559158 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -52,7 +52,7 @@ from common import ( ) yaml = require_yaml() -from nativemode import reads_file_contents +from nativemode import hash_mismatch_excludes_file, reads_file_contents from validation import ( _build_validation_index, _parse_validation, @@ -351,6 +351,12 @@ def find_undeclared_files( if declared_names is None: declared_names = expand_platform_declared_names(config, db) + # Whether the builder drops a file whose local copy contradicts its + # declared hash, which decides if such a copy counts as held here. + shipped_on_mismatch = hash_mismatch_excludes_file( + config.get("verification_mode") + ) + # Collect data_directory refs declared_dd: set[str] = set() for sys_id, system in config.get("systems", {}).items(): @@ -503,6 +509,14 @@ def find_undeclared_files( storage = f.get("storage", "") if storage in ("release", "large_file"): in_repo = True + elif shipped_on_mismatch and (f.get("md5") or f.get("sha1")): + # The entry states what its content should be and the builder + # drops a copy that contradicts it, so content decides here + # too. A name match would not do: generic names collide across + # systems, and answering yes on one describes a pack that will + # not contain the file. + _lp, _st = resolve_local_file(f, db, dest_hint=dest) + in_repo = _lp is not None and _st != "hash_mismatch" else: in_repo = _name_in_index( fname, by_name, by_path_suffix, data_names, by_name_lower, @@ -515,13 +529,19 @@ def find_undeclared_files( ) if not in_repo: # Hash fallback: the repo may hold the content under a - # different filename (exos21.rom vs exos21.bin) - # generate_pack ships a core extra whose local copy - # contradicts the declared hash and reports the divergence. - # verify must agree with the builder or the two reports - # disagree on the same file. + # different filename (exos21.rom vs exos21.bin). + # + # A copy contradicting the declared hash counts as held + # only where the builder would ship it. Under existence + # the frontend never opens the file, so the pack carries + # it and reports the divergence; under a digest mode the + # frontend would reject it, the builder leaves it out, and + # calling it available here would describe a pack that + # does not contain it. _lp, _st = resolve_local_file(f, db, dest_hint=dest) in_repo = _st != "not_found" and _lp is not None + if in_repo and _st == "hash_mismatch" and shipped_on_mismatch: + in_repo = False checks = _parse_validation(f.get("validation")) undeclared.append( diff --git a/tests/test_native_mode.py b/tests/test_native_mode.py index d43ba170..1e1c2e1b 100644 --- a/tests/test_native_mode.py +++ b/tests/test_native_mode.py @@ -116,5 +116,100 @@ class BothConsumersAgree(unittest.TestCase): ) +class GapAnalysisAgreesWithTheBuilder(unittest.TestCase): + """"Available" must mean the pack will carry it. + + find_undeclared_files answered from the name index, so a core extra whose + local copy contradicts its declared hash counted as held. Under a digest + mode the builder drops exactly that file, so the coverage report described + a pack that would not contain it: seven such files across three platforms. + """ + + def _fixture(self): + import hashlib + import tempfile + + tmp = tempfile.TemporaryDirectory() + root = Path(tmp.name) + (root / "emulators").mkdir() + rom = root / "collide.rom" + rom.write_bytes(b"THE BYTES THE COLLECTION HOLDS") + sha1 = hashlib.sha1(rom.read_bytes()).hexdigest() + db = { + "files": { + sha1: { + "path": str(rom), + "name": "collide.rom", + "size": rom.stat().st_size, + "sha1": sha1, + "md5": hashlib.md5(rom.read_bytes()).hexdigest(), + "sha256": hashlib.sha256(rom.read_bytes()).hexdigest(), + "crc32": "00000000", + } + }, + "indexes": { + "by_name": {"collide.rom": [sha1]}, + "by_md5": {hashlib.md5(rom.read_bytes()).hexdigest(): sha1}, + "by_sha256": {}, + "by_crc32": {}, + "by_path_suffix": {}, + }, + } + (root / "emulators" / "demo.yml").write_text( + "emulator: demo\n" + "type: libretro\n" + "display_name: Demo\n" + "systems: [demo-system]\n" + "cores: [demo]\n" + "files:\n" + " - name: collide.rom\n" + " system: demo-system\n" + " required: true\n" + " md5: \"" + "f" * 32 + "\"\n" + ) + return tmp, root, db + + def _in_repo(self, mode: str) -> bool: + import common + from verify import find_undeclared_files + + tmp, root, db = self._fixture() + try: + common._emulator_profiles_cache.clear() + profiles = common.load_emulator_profiles(str(root / "emulators")) + config = { + "platform": "Demo", + "verification_mode": mode, + "cores": ["demo"], + "systems": {}, + } + found = find_undeclared_files( + config, str(root / "emulators"), db, emu_profiles=profiles + ) + entry = next(e for e in found if e["name"] == "collide.rom") + return bool(entry["in_repo"]) + finally: + common._emulator_profiles_cache.clear() + tmp.cleanup() + + def test_a_digest_mode_does_not_call_a_contradicted_copy_available(self): + self.assertFalse( + self._in_repo("md5"), + "the builder drops this file, so the report must not count it", + ) + + def test_existence_mode_still_counts_it(self): + """The frontend never opens the file, so the pack carries it.""" + self.assertTrue(self._in_repo("existence")) + + def test_the_two_modes_answer_the_way_the_shared_predicate_says(self): + for mode in nativemode.MODES: + with self.subTest(mode=mode): + self.assertEqual( + self._in_repo(mode), + not nativemode.hash_mismatch_excludes_file(mode), + ) + + if __name__ == "__main__": unittest.main()