diff --git a/scripts/exporter/base_exporter.py b/scripts/exporter/base_exporter.py index d4189bb8..90381bb9 100644 --- a/scripts/exporter/base_exporter.py +++ b/scripts/exporter/base_exporter.py @@ -133,6 +133,19 @@ class BaseExporter(ABC): require = require or cls.requires() return not require or bool(fe.hash(require)) + @staticmethod + def single_value(fe: NativeFile, field_name: str) -> str: + """The one hash a single-value field is checked against. + + A platform that leaves the field empty checks the file by existence. + Filling it turns that into a content check on one value: right when + the truth accepts exactly one image, wrong when the core takes several + and the field can hold only the first. Such a fill stays empty. + """ + if field_name in fe.filled and len(set(fe.hashes(field_name))) != 1: + return "" + return fe.hash(field_name) + def states(self, fe: NativeFile, field_name: str) -> bool: """Whether the written file carries this entry's corrected field. diff --git a/scripts/exporter/batocera_exporter.py b/scripts/exporter/batocera_exporter.py index 21902a3c..871f2d4e 100644 --- a/scripts/exporter/batocera_exporter.py +++ b/scripts/exporter/batocera_exporter.py @@ -53,6 +53,11 @@ class Exporter(BaseExporter): return False return super().writable(fe, require) + def states(self, fe: NativeFile, field_name: str) -> bool: + if field_name == "md5" and "md5" in fe.filled: + return bool(self.single_value(fe, "md5")) + return super().states(fe, field_name) + @staticmethod def native_sources() -> dict[str, str]: return {"batocera-systems": SOURCE_URL} @@ -89,7 +94,7 @@ class Exporter(BaseExporter): for fe in files: # The platform states an unhashed file as an empty md5 rather # than leaving it out, and so do we. - item = [f'"md5": "{fe.hash("md5")}"'] + item = [f'"md5": "{self.single_value(fe, "md5")}"'] alt = fe.native("alt_md5", "") if alt: item.append(f'"altmd5": "{alt}"') diff --git a/scripts/exporter/retrobat_exporter.py b/scripts/exporter/retrobat_exporter.py index 1f014a91..c5f89774 100644 --- a/scripts/exporter/retrobat_exporter.py +++ b/scripts/exporter/retrobat_exporter.py @@ -41,22 +41,9 @@ class Exporter(BaseExporter): def native_sources() -> dict[str, str]: return {"batocera-systems.json": SOURCE_URL} - @staticmethod - def _md5(fe: NativeFile) -> str: - """The one md5 the entry is checked against, empty for existence. - - RetroBat writes an empty md5 to check a file by existence. Filling it - turns that into a content check on a single value: right when the - truth accepts exactly one image, wrong when the core takes several - and the field can hold only the first. - """ - if "md5" in fe.filled and len(fe.hashes("md5")) != 1: - return "" - return fe.hash("md5") - def states(self, fe: NativeFile, field_name: str) -> bool: if field_name == "md5" and "md5" in fe.filled: - return bool(self._md5(fe)) + return bool(self.single_value(fe, "md5")) return super().states(fe, field_name) def render( @@ -94,7 +81,7 @@ class Exporter(BaseExporter): ours: OrderedDict[str, OrderedDict[str, str]] = OrderedDict() for fe in files: entry: OrderedDict[str, str] = OrderedDict() - entry["md5"] = self._md5(fe) + entry["md5"] = self.single_value(fe, "md5") declared = fe.native("native_path", "") entry["file"] = str(declared) if declared else f"bios/{fe.destination}" ours.setdefault(entry["file"], entry) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index f79fa884..daca4e9d 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -663,6 +663,59 @@ class BatoceraExport(unittest.TestCase): ) self.assertIn('"md5": ""', produced["batocera-systems"]) + def test_a_blank_md5_is_not_filled_with_one_of_several_dumps(self): + """checkBios compares one md5: the first of two refuses the other. + + The empty field checks by existence, so a user holding the second + dump the core accepts would turn from OK to UNTESTED. + """ + first, second = "1" * 32, "2" * 32 + truth = { + "systems": { + "nintendo-ds": { + "files": [ + { + "name": "firmware.bin", + "path": "firmware.bin", + "md5": [first, second], + "required": True, + "_cores": ["melonds"], + } + ] + } + } + } + scraped = { + "systems": { + "nintendo-ds": { + "native_id": "nds", + "files": [ + { + "name": "firmware.bin", + "destination": "firmware.bin", + "native_system": "nds", + "md5": "", + } + ], + } + } + } + for platform, filename in ( + ("batocera", "batocera-systems"), + ("rocknix", "rocknix-systems"), + ): + with self.subTest(platform=platform): + systems, report = build_native_model(truth, scraped) + (entry,) = systems["nds"].files + self.assertIn("md5", entry.filled) + exporter = discover_exporters()[platform]() + produced = exporter.render( + systems, report, {filename: self.SKELETON}, None + ) + self.assertIn('"md5": ""', produced[filename]) + self.assertNotIn(first, produced[filename]) + self.assertFalse(exporter.states(entry, "md5")) + def test_writing_without_the_platform_file_is_refused(self): """A systems dict alone is not the script Batocera runs.""" systems, report = model()