From d904116a79610aaeaf29853975690c2536db42e8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:03:27 +0200 Subject: [PATCH] fix: verify each 3DS file once per content check_crypto_validation keeps its answer keyed by the file and keys file as compute_hashes keys them (path, inode, size, mtime, ctime). verify --all ran the verifiers 35 times for 5 files (0.88 s), now 5 times (0.13 s). verify --all --json output is byte-identical. --- scripts/crypto_verify.py | 28 +++++++++++++++++++++++--- tests/test_crypto_verify.py | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/scripts/crypto_verify.py b/scripts/crypto_verify.py index 081ee2b5..efe75406 100644 --- a/scripts/crypto_verify.py +++ b/scripts/crypto_verify.py @@ -490,6 +490,23 @@ _CRYPTO_VERIFIERS: dict[str, Callable] = { } +def _stat_key(path: str | Path) -> tuple | None: + """(path, inode, size, mtime_ns, ctime_ns): the file as compute_hashes + keys it, None when it cannot be read.""" + try: + stat = Path(path).stat() + except OSError: + return None + return (str(Path(path).resolve()), stat.st_ino, stat.st_size, + stat.st_mtime_ns, stat.st_ctime_ns) + + +# (verifier, file key, keys file key) -> reason or None. verify --all ran the +# curve and RSA checks of the same five files once per platform, 35 runs for +# 5 answers: the answer depends on the file's bytes and the keys file alone. +_crypto_cache: dict[tuple, str | None] = {} + + def check_crypto_validation( local_path: str, filename: str, @@ -508,8 +525,13 @@ def check_crypto_validation( if not keys_file: return "crypto check skipped: no keys file found" + file_key, keys_key = _stat_key(local_path), _stat_key(keys_file) + key = (filename, file_key, keys_key) if file_key and keys_key else None + if key in _crypto_cache: + return _crypto_cache[key] keys = parse_keys_file(keys_file) valid, reason = verifier(local_path, keys) - if valid: - return None - return reason + result = None if valid else reason + if key is not None: + _crypto_cache[key] = result + return result diff --git a/tests/test_crypto_verify.py b/tests/test_crypto_verify.py index 14b563e5..53e8e1ab 100644 --- a/tests/test_crypto_verify.py +++ b/tests/test_crypto_verify.py @@ -19,6 +19,7 @@ import shutil import sys import tempfile import unittest +import unittest.mock from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -329,5 +330,44 @@ class KeysFile(unittest.TestCase): self.assertIsNone(crypto_verify.find_keys_file(self.tmp)) +class OneFileIsVerifiedOncePerContent(unittest.TestCase): + """verify --all ran the 3DS checks of the same five files once per + platform: 35 runs of pure-Python curve and RSA work for 5 answers.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.bios = Path(self._tmp.name) + target = self.bios / "Nintendo" / "3DS" + target.mkdir(parents=True) + (target / "aes_keys.txt").write_text(":AES\n") + self.file = target / "otp.bin" + self.file.write_bytes(b"\x00" * 0x100) + self.calls: list[bytes] = [] + + def verifier(path, keys): + data = Path(path).read_bytes() + self.calls.append(data) + return data[0] == 1, "first byte is not 1" + + patcher = unittest.mock.patch.dict(crypto_verify._CRYPTO_VERIFIERS, {"otp.bin": verifier}) + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(crypto_verify._crypto_cache.clear) + self.addCleanup(self._tmp.cleanup) + + def check(self) -> str | None: + return crypto_verify.check_crypto_validation(str(self.file), "otp.bin", str(self.bios)) + + def test_the_same_file_is_verified_once(self): + self.assertEqual([self.check() for _ in range(7)], ["first byte is not 1"] * 7) + self.assertEqual(len(self.calls), 1) + + def test_a_rewritten_file_is_verified_again(self): + self.assertEqual(self.check(), "first byte is not 1") + self.file.write_bytes(b"\x01" + b"\x00" * 0x1FF) + self.assertIsNone(self.check()) + self.assertEqual(len(self.calls), 2) + + if __name__ == "__main__": unittest.main()