From a61c80046216ade056be42b3b10beef6d58c3adb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:08:04 +0200 Subject: [PATCH] fix: report a contradicted hash on an archive too --- scripts/verify.py | 16 ++++- tests/test_verify_emulator_evidence.py | 81 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/scripts/verify.py b/scripts/verify.py index 1b476237..782d74ca 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1402,7 +1402,7 @@ def verify_emulator( ), {"name": archive}, ) - local_path, _ = resolve_local_file( + local_path, resolve_status = resolve_local_file( archive_entry, db, zip_contents, @@ -1412,7 +1412,19 @@ def verify_emulator( f.get("archive") == archive and f.get("required", True) for f in files ) - if local_path: + if local_path and resolve_status == "hash_mismatch": + # Same policy as the loose-file branch below: the name + # matched while the bytes contradict the hash the profile + # declares on the container. Reporting it covered says the + # collection holds an archive it does not. + result = { + "name": archive, + "status": Status.UNTESTED, + "required": required, + "path": local_path, + "reason": "declared hash contradicted by the local file", + } + elif local_path: result = { "name": archive, "status": Status.OK, diff --git a/tests/test_verify_emulator_evidence.py b/tests/test_verify_emulator_evidence.py index b4941b77..a103234d 100644 --- a/tests/test_verify_emulator_evidence.py +++ b/tests/test_verify_emulator_evidence.py @@ -154,6 +154,87 @@ class HashMismatchIsNotCoverage(unittest.TestCase): self.assertEqual(detail["status"], verify.Status.MISSING) +class ArchiveHashMismatchIsNotCoverage(unittest.TestCase): + """The archive branch of the same report applied a different policy. + + It discarded the status resolve_local_file returns and called any + non-empty path OK, while the loose-file branch beside it reported the + divergence. Eighteen archive entries declare a container hash, so an + archive whose bytes contradict it would have read as covered. + """ + + def setUp(self): + import zipfile + + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.emulators = self.root / "emulators" + self.emulators.mkdir() + self.archive = self.root / "romset.zip" + with zipfile.ZipFile(self.archive, "w") as zf: + zf.writestr("inner.rom", b"THE MEMBER THE EMULATOR LOADS") + self.db = _db(self.archive, "romset.zip") + from common import _emulator_profiles_cache + + _emulator_profiles_cache.clear() + + def tearDown(self): + from common import _emulator_profiles_cache + + _emulator_profiles_cache.clear() + self._tmp.cleanup() + + def _profile(self, declared_md5: str) -> None: + (self.emulators / "demo.yml").write_text( + "emulator: demo\n" + "type: standalone\n" + "display_name: Demo\n" + "systems: [demo-system]\n" + "files:\n" + " - name: romset.zip\n" + " system: demo-system\n" + " category: bios_zip\n" + " required: true\n" + f" md5: \"{declared_md5}\"\n" + " - name: inner.rom\n" + " system: demo-system\n" + " archive: romset.zip\n" + " required: true\n" + ) + + def _run(self): + cwd = os.getcwd() + os.chdir(self.root) + try: + return verify.verify_emulator(["demo"], str(self.emulators), self.db) + finally: + os.chdir(cwd) + + def _statuses(self, result): + """Every verdict on the archive. + + The container is reported twice, once by the branch that walks the + profile's files and once by the branch that walks its archives. Keying + by name keeps only the last, which is how a passing test can hide the + branch under examination. + """ + return [d["status"] for d in result["details"] if d["name"] == "romset.zip"] + + def test_a_contradicted_container_hash_is_not_reported_ok(self): + self._profile("f" * 32) + statuses = self._statuses(self._run()) + self.assertEqual(len(statuses), 2, statuses) + self.assertNotIn( + verify.Status.OK, + statuses, + "an archive whose bytes contradict its declared hash counted as covered", + ) + + def test_a_matching_container_hash_is_still_ok(self): + self._profile(hashlib.md5(self.archive.read_bytes()).hexdigest()) + self.assertEqual(set(self._statuses(self._run())), {verify.Status.OK}) + + class UnsourceableIsNotAGap(unittest.TestCase): """An entry nobody can supply is absent by design, not by omission.