diff --git a/emulators/azahar.yml b/emulators/azahar.yml index f28b0a66..8decbb48 100644 --- a/emulators/azahar.yml +++ b/emulators/azahar.yml @@ -73,6 +73,7 @@ files: required: false size: [288, 320] validation: [size, signature] + on_mismatch: {signature: warn} note: "console-unique key seed; 288 bytes without the AES MAC block, 320 with it" source_ref: "src/core/hw/unique_data.cpp:170-200, src/core/hw/unique_data.cpp:217, src/core/hw/unique_data.h:103-133" @@ -82,6 +83,7 @@ files: required: false size: 273 validation: [size, signature] + on_mismatch: {signature: warn} note: "console serial and region data" source_ref: "src/core/hw/unique_data.cpp:43-92, src/core/hw/unique_data.cpp:205" @@ -91,5 +93,6 @@ files: required: false size: 272 validation: [size, signature] + on_mismatch: {signature: warn} note: "friend code generation seed" source_ref: "src/core/hw/unique_data.cpp:94-123, src/core/hw/unique_data.cpp:209, src/core/hw/unique_data.h:76" diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index d30227a4..fbbc9ccd 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -657,10 +657,27 @@ "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" + "description": "What the code does when a declared check fails: reject the file (default) or warn and run with it, for every check or check by check.", + "oneOf": [ + { + "enum": [ + "reject", + "warn" + ] + }, + { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^(size|crc32|md5|sha1|sha256|adler32|signature|crypto)$": { + "enum": [ + "reject", + "warn" + ] + } + } + } ] } }, diff --git a/scripts/validation.py b/scripts/validation.py index f0f82040..6ae812bc 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -95,31 +95,52 @@ 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", + # The checks whose failure the code only warns about: the file runs. + "warn": warned_checks(f, checks), } +def warned_checks(f: dict, checks: set[str]) -> set[str]: + """The checks of an entry the code warns about instead of refusing. + + `on_mismatch: warn` covers every check; a map names them one by one, as + Azahar refuses a SecureInfo_A of the wrong size and only logs a bad + signature. + """ + level = f.get("on_mismatch") + if level == "warn": + return set(checks) + if isinstance(level, dict): + return {check for check, value in level.items() if value == "warn"} & set(checks) + return set() + + def _rule_failure( rule: dict, local_path: str, filename: str, bios_dir: str, hashes: dict, + strict: bool = False, ) -> str | None: - """Why one entry's checks reject a file, or None when they accept it.""" - actual_size = os.path.getsize(local_path) - if rule["sizes"] and actual_size not in rule["sizes"]: - accepted = ",".join(str(s) for s in sorted(rule["sizes"])) - return f"size mismatch: got {actual_size}, accepted [{accepted}]" - if rule["min_size"] is not None and actual_size < rule["min_size"]: - return f"size too small: min {rule['min_size']}, got {actual_size}" - if rule["max_size"] is not None and actual_size > rule["max_size"]: - return f"size too large: max {rule['max_size']}, got {actual_size}" + """Why one entry's checks reject a file, or None when they accept it. + + A check the code only warns about counts under ``strict`` alone. + """ + skip = set() if strict else rule["warn"] + if "size" not in skip: + actual_size = os.path.getsize(local_path) + if rule["sizes"] and actual_size not in rule["sizes"]: + accepted = ",".join(str(s) for s in sorted(rule["sizes"])) + return f"size mismatch: got {actual_size}, accepted [{accepted}]" + if rule["min_size"] is not None and actual_size < rule["min_size"]: + return f"size too small: min {rule['min_size']}, got {actual_size}" + if rule["max_size"] is not None and actual_size > rule["max_size"]: + return f"size too large: max {rule['max_size']}, got {actual_size}" for hash_type in ("crc32", "md5", "sha1", "sha256"): - if rule[hash_type]: + if rule[hash_type] and hash_type not in skip: if not hashes: hashes.update(compute_hashes(local_path)) if hashes[hash_type].lower() not in rule[hash_type]: accepted = ",".join(sorted(rule[hash_type])) return f"{hash_type} mismatch: got {hashes[hash_type]}, accepted [{accepted}]" - if rule["adler32"]: + if rule["adler32"] and "adler32" not in skip: if rule["adler32_byteswap"]: actual = _adler32_byteswapped(local_path) else: @@ -129,7 +150,7 @@ def _rule_failure( if actual not in rule["adler32"]: accepted = ",".join(sorted(rule["adler32"])) return f"adler32 mismatch: got 0x{actual}, accepted [{accepted}]" - if rule["crypto"]: + if rule["crypto"] - skip: from crypto_verify import check_crypto_validation return check_crypto_validation(local_path, filename, bios_dir) @@ -182,8 +203,11 @@ 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. + # A warned check is never a rejection, so it stays out of the + # merged rejection rules. + warned = warned_checks(f, set(checks)) + checks = [check for check in checks if check not in warned] + if not checks: continue index[fname]["checks"].update(checks) # Track non-reproducible crypto checks @@ -348,8 +372,7 @@ def check_file_validation( if not rules: continue reasons = [ - None if rule["warn"] and not strict - else _rule_failure(rule, local_path, filename, bios_dir, hashes) + _rule_failure(rule, local_path, filename, bios_dir, hashes, strict) for rule in rules ] if all(reasons): diff --git a/tests/test_profile_checks.py b/tests/test_profile_checks.py index 309bec63..8624b9ed 100644 --- a/tests/test_profile_checks.py +++ b/tests/test_profile_checks.py @@ -329,5 +329,20 @@ class AltirraHappy810(_Probe): self.assertTrue(self.accepts("altirra", "happy-810-prev7-4k.rom", b"\5" * 5000)) +class AzaharConsoleUniqueData(_Probe): + """LoadSecureInfoA, LoadLocalFriendCodeSeedB and LoadMovable refuse a + file of the wrong size, and only log a failed signature: the data is + loaded and used (src/core/hw/unique_data.cpp:43-123, 165-202, + src/core/hle/service/cfg/cfg.cpp:405-410 at 9dd12da).""" + + def test_size_refuses_and_signature_only_warns(self): + for name, size in (("SecureInfo_A", 273), ("LocalFriendCodeSeed_B", 272), + ("movable.sed", 288)): + with self.subTest(name=name): + self.assertTrue(self.accepts("azahar", name, b"\0" * size)) + self.assertFalse(self.accepts("azahar", name, b"\0" * size, strict=True)) + self.assertFalse(self.accepts("azahar", name, b"\0" * (size + 7))) + + if __name__ == "__main__": unittest.main()