diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index 3f8a838e..2b503598 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -642,6 +642,13 @@ }, "unsourceable": { "type": "string" + }, + "on_mismatch": { + "description": "What the code does when a declared check fails: reject the file (default) or warn and run with it.", + "enum": [ + "reject", + "warn" + ] } }, "allOf": [ diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index b2991890..3bf20722 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -85,9 +85,7 @@ from validation import ( agnostic_substitute, frontend_digest_matches, _build_validation_index, - check_file_validation, filter_files_by_mode, - find_validated_variant, ) yaml = require_yaml() @@ -1531,17 +1529,15 @@ def generate_emulator_pack( # The file verify --emulator credits is the one shipped: # a dump the core's own check rejects gives way to a held # one it accepts (azahar's otp.bin, dolphin's dsp_rom.bin). - refused = check_file_validation( - local_path, fe["name"], validation_index, bios_dir, {emu_name} + better, disagreement = validated_choice( + fe, local_path, db, validation_index, bios_dir, None, + emulators={emu_name}, ) - if refused: - better = find_validated_variant( - fe, db, local_path, validation_index, bios_dir, - emulators={emu_name}, - ) - if not better: - refusal = refusal or f"{fe['name']} -{refused[0]}" - continue + if disagreement: + reason = disagreement.split(" says ", 1)[-1] + refusal = refusal or f"{fe['name']} -{reason}" + continue + if better != local_path: chosen = (fe, better, None) break if status == "hash_mismatch": diff --git a/scripts/validation.py b/scripts/validation.py index b216b6e3..5691cd70 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -95,6 +95,8 @@ def _entry_rule(emu_name: str, f: dict, checks: set[str]) -> dict: }, "adler32_byteswap": bool(f.get("adler32_byteswap")), "crypto": {c for c in checks if c in _CRYPTO_CHECKS}, + # A failed check the code only warns about: the file still runs. + "warn": f.get("on_mismatch") == "warn", } @@ -180,6 +182,9 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: } index[fname]["rules"].append(_entry_rule(emu_name, f, checks)) index[fname]["emulators"].add(emu_name) + if f.get("on_mismatch") == "warn": + # Never a rejection, so not part of the merged rejection rules. + continue index[fname]["checks"].update(checks) # Track non-reproducible crypto checks index[fname]["crypto_only"].update(c for c in checks if c in _CRYPTO_CHECKS) @@ -314,6 +319,7 @@ def check_file_validation( validation_index: dict[str, dict], bios_dir: str = "bios", emulators: set[str] | None = None, + strict: bool = False, ) -> tuple[str, list[str]] | None: """Check emulator-level validation on a resolved file. @@ -323,7 +329,9 @@ def check_file_validation( With ``emulators``, the cores that read the file's destination, each of them must accept it through one of its own entries for the name, the way each runs its own check. Without, the checks of every core naming - the file are merged. + the file are merged. A check declared `on_mismatch: warn` rejects + nothing, since the core runs with the file; ``strict`` counts it, to + prefer a file that passes it too. Returns None if all checks pass or no validation applies. Returns (reason, emulators) tuple on failure, where *emulators* @@ -340,7 +348,8 @@ def check_file_validation( if not rules: continue reasons = [ - _rule_failure(rule, local_path, filename, bios_dir, hashes) + None if rule["warn"] and not strict + else _rule_failure(rule, local_path, filename, bios_dir, hashes) for rule in rules ] if all(reasons): @@ -470,6 +479,7 @@ def find_validated_variant( bios_dir: str = "bios", platform_digest: str | None = None, emulators: set[str] | None = None, + strict: bool = False, ) -> str | None: """A held file the emulator's own checks accept, in place of current_path. @@ -529,7 +539,7 @@ def find_validated_variant( if accepted and str(entry.get(platform_digest, "")).lower() not in accepted: continue if check_file_validation( - path, fname, validation_index, bios_dir, emulators + path, fname, validation_index, bios_dir, emulators, strict ) is None: return path return None @@ -668,6 +678,7 @@ def validated_choice( platform_digest: str | None, destination: str = "", owners: dict | None = None, + emulators: set[str] | None = None, ) -> tuple[str | None, str | None]: """The file a platform destination ships, and the disagreement if any. @@ -681,8 +692,8 @@ def validated_choice( return local_path, None name = file_entry.get("name", "") rules = validation_index.get(name) - judges: set[str] | None = None - if rules and owners and destination: + judges: set[str] | None = emulators + if rules and judges is None and owners and destination: owning = _owners_of(destination, name, owners) if not owning & set(rules["emulators"]): # No core that checks this name reads this destination: PC-98's @@ -691,19 +702,31 @@ def validated_choice( # The cores that read this destination judge it, each by its own # entries. judges = owning & set(rules["emulators"]) - check = check_file_validation( - local_path, file_entry.get("name", ""), validation_index, bios_dir, judges - ) - if not check: + def check(path: str, strict: bool): + return check_file_validation(path, name, validation_index, bios_dir, judges, strict) + + def variant(strict: bool): + return find_validated_variant( + file_entry, db, local_path, validation_index, bios_dir, + platform_digest=platform_digest, emulators=judges, strict=strict, + ) + + if not check(local_path, True): return local_path, None - better = find_validated_variant( - file_entry, db, local_path, validation_index, bios_dir, - platform_digest=platform_digest, emulators=judges, - ) + # A held file every check passes, warn-level ones included, comes first; + # then the file itself when only a warning fails (the core runs it); + # then a held file the core at least runs. + better = variant(True) if better: return better, None - reason, emulators = check - return local_path, f"{', '.join(emulators)} says {reason}" + failed = check(local_path, False) + if not failed: + return local_path, None + better = variant(False) + if better: + return better, None + reason, failing = failed + return local_path, f"{', '.join(failing)} says {reason}" def existence_discrepancy(file_entry: dict, local_path: str, platform_display: str) -> str: diff --git a/scripts/verify.py b/scripts/verify.py index ea4ece15..7905513e 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -81,7 +81,6 @@ from validation import ( build_ground_truth, check_file_validation, filter_files_by_mode, - find_validated_variant, outside_gap_scope, ) @@ -1585,31 +1584,27 @@ def verify_emulator( ) else: # Apply emulator validation - # Judged by this emulator's own entries, as it runs its check. - check = check_file_validation( - local_path, name, validation_index, emulators={emu_name} + # Judged by this emulator's own entries, as it runs its check, + # through the selection the emulator pack makes. + chosen, disagreement = validated_choice( + file_entry, local_path, db, validation_index, DEFAULT_BIOS_DIR, + None, emulators={emu_name}, ) - if check: - reason, _emus = check - better = find_validated_variant( - file_entry, db, local_path, validation_index, - emulators={emu_name}, - ) - if better: - result = { - "name": name, - "status": Status.OK, - "required": required, - "path": better, - } - else: - result = { - "name": name, - "status": Status.UNTESTED, - "required": required, - "path": local_path, - "reason": reason, - } + if disagreement: + result = { + "name": name, + "status": Status.UNTESTED, + "required": required, + "path": local_path, + "reason": disagreement.split(" says ", 1)[-1], + } + elif chosen != local_path: + result = { + "name": name, + "status": Status.OK, + "required": required, + "path": chosen, + } elif resolve_status == "hash_mismatch": # Nothing in validation: caught it, but the name matched # while the bytes contradict the hash the profile diff --git a/tests/test_validated_choice.py b/tests/test_validated_choice.py index f3ac1f38..d509abd1 100644 --- a/tests/test_validated_choice.py +++ b/tests/test_validated_choice.py @@ -215,5 +215,52 @@ class DolphinForksJudgeTheirOwnDspTable(unittest.TestCase): ) self.assertEqual(verdict is None, label == accepted, label) + +class AWarningRejectsNothing(unittest.TestCase): + """BeebEm warns about a short ROM and runs it; Azahar warns about a bad + signature and runs the file. A warn-level check never refuses a file, yet + a held file that passes it is preferred.""" + + def _setup(self, tmp, held_good: bool): + short = Path(tmp) / "a" / "OS12.rom" + good = Path(tmp) / "b" / "OS12.rom" + short.parent.mkdir() + good.parent.mkdir() + short.write_bytes(b"s" * 8192) + good.write_bytes(b"g" * 16384) + files = [short] + ([good] if held_good else []) + db = {"files": {}, "indexes": {"by_name": {"OS12.rom": []}, "by_md5": {}}} + for f in files: + sha1 = hashlib.sha1(f.read_bytes()).hexdigest() + db["files"][sha1] = {"path": str(f), "name": "OS12.rom", "size": f.stat().st_size} + db["indexes"]["by_name"]["OS12.rom"].append(sha1) + index = _build_validation_index({"beebem": {"files": [ + {"name": "OS12.rom", "size": 16384, "validation": ["size"], "on_mismatch": "warn"}, + ]}}) + return short, good, db, index + + def test_the_core_runs_a_file_that_only_warns(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + short, _good, db, index = self._setup(tmp, held_good=False) + chosen, disagreement = validated_choice( + {"name": "OS12.rom"}, str(short), db, index, tmp, None, emulators={"beebem"} + ) + self.assertEqual((chosen, disagreement), (str(short), None)) + + def test_a_file_passing_the_warned_check_is_preferred(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + short, good, db, index = self._setup(tmp, held_good=True) + chosen, disagreement = validated_choice( + {"name": "OS12.rom"}, str(short), db, index, tmp, None, emulators={"beebem"} + ) + self.assertEqual((chosen, disagreement), (str(good), None)) + + def test_the_merged_view_rejects_nothing_for_it(self): + from validation import check_file_validation # noqa: PLC0415 + + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + short, _good, _db, index = self._setup(tmp, held_good=False) + self.assertIsNone(check_file_validation(str(short), "OS12.rom", index, tmp)) + if __name__ == "__main__": unittest.main()