diff --git a/cuda_core/cuda/core/utils/_program_cache/_keys.py b/cuda_core/cuda/core/utils/_program_cache/_keys.py index e170bc18131..7c8719e9104 100644 --- a/cuda_core/cuda/core/utils/_program_cache/_keys.py +++ b/cuda_core/cuda/core/utils/_program_cache/_keys.py @@ -669,7 +669,8 @@ def make_program_cache_key( ``use_pch``, ``pch_dir``) -- the cache cannot read those files on the caller's behalf, so the caller must fingerprint the header / PCH surface and pass it here. Callers may pass this for other - inputs too (embedded kernels, generated sources, etc.). + inputs too (embedded kernels, generated sources, etc.). Must be + ``bytes``, ``bytearray``, ``memoryview``, or ``None``. Returns ------- @@ -686,6 +687,8 @@ def make_program_cache_key( If ``extra_digest`` is ``None`` while ``options`` sets any option whose compilation effect depends on external file content that the key cannot otherwise observe. + TypeError + If ``extra_digest`` is neither ``None`` nor a bytes-like object. Examples -------- @@ -751,6 +754,20 @@ def make_program_cache_key( # init and target_type at the top of compile); a caller that passes # "PTX" or "C++" must get the same routing and the same cache key as # the lowercase form. + # Check the digest's type before anything else: every guard below asks + # only whether it ``is not None``, so a non-bytes value satisfies the + # requirement to supply a digest for options that read external files + # while contributing nothing to the key. ``extra_digest=0`` is the worst + # shape -- ``bytes(0)`` is empty, so the key is byte-identical to one + # built with ``extra_digest=b""`` and is completely blind to the header + # content the caller was asked to fingerprint. + if extra_digest is not None and not isinstance(extra_digest, bytes | bytearray | memoryview): + raise TypeError( + f"extra_digest must be bytes, bytearray, memoryview, or None; got " + f"{type(extra_digest).__name__}. It satisfies the guards that require a " + f"digest for options reading external files, but contributes no digest bytes." + ) + code_type = code_type.lower() if isinstance(code_type, str) else code_type target_type = target_type.lower() if isinstance(target_type, str) else target_type check_str_enum(code_type, SourceCodeType) diff --git a/cuda_core/docs/source/release/1.2.0-notes.rst b/cuda_core/docs/source/release/1.2.0-notes.rst index 120d2c2a253..0994e7ded4e 100644 --- a/cuda_core/docs/source/release/1.2.0-notes.rst +++ b/cuda_core/docs/source/release/1.2.0-notes.rst @@ -73,6 +73,13 @@ Fixes and enhancements Windows, both ``ctypes.CFUNCTYPE`` and ``ctypes.WINFUNCTYPE`` are accepted. (`#2439 `__) +- :func:`~cuda.core.utils.make_program_cache_key` now requires ``extra_digest`` + to be bytes-like. The guards that demand a digest for options reading + external files only asked whether it was not ``None``, so a non-bytes value + such as ``0`` satisfied them while contributing nothing to the key -- + ``bytes(0)`` is empty, so the resulting key was blind to the header contents + it was supposed to fingerprint. + Deprecation Notices ------------------- diff --git a/cuda_core/tests/test_program_cache.py b/cuda_core/tests/test_program_cache.py index a8d3fc85f7e..662d27b77ef 100644 --- a/cuda_core/tests/test_program_cache.py +++ b/cuda_core/tests/test_program_cache.py @@ -348,6 +348,60 @@ def test_make_program_cache_key_rejects(kwargs, exc_type, match): _make_key(**kwargs) +@pytest.mark.agent_authored(model="claude-opus-5") +@pytest.mark.parametrize( + "bad", + [ + # 0 is the sharp one: it satisfies every `extra_digest is not None` + # guard, and bytes(0) is empty, so the key it produces is identical to + # one built with b"" and carries no fingerprint at all. + pytest.param(0, id="int-zero"), + pytest.param(5, id="int-nonzero"), + pytest.param(-1, id="int-negative"), + pytest.param(True, id="bool"), + pytest.param("abcd", id="str"), + pytest.param(1.5, id="float"), + pytest.param(["abcd"], id="list"), + ], +) +def test_make_program_cache_key_rejects_non_bytes_extra_digest(bad): + with pytest.raises(TypeError, match="extra_digest must be bytes"): + _make_key(extra_digest=bad) + + +@pytest.mark.agent_authored(model="claude-opus-5") +@pytest.mark.parametrize( + "digest", + [ + pytest.param(b"\x01" * 32, id="bytes"), + pytest.param(bytearray(b"\x01" * 32), id="bytearray"), + pytest.param(memoryview(b"\x01" * 32), id="memoryview"), + ], +) +def test_make_program_cache_key_accepts_bytes_like_extra_digest(digest): + """All three bytes-like spellings are accepted and hash identically.""" + assert _make_key(extra_digest=digest) == _make_key(extra_digest=b"\x01" * 32) + + +@pytest.mark.agent_authored(model="claude-opus-5") +def test_non_bytes_extra_digest_cannot_bypass_the_external_content_guard(): + """The external-content guard asks only whether extra_digest `is not None`. + + A caller passing 0 -- a plausible "no digest yet" sentinel -- used to slip + past it and get a persistent key that is blind to the header contents it + was supposed to fingerprint, which is exactly what the guard exists to + prevent. + """ + with pytest.raises(ValueError, match="without an extra_digest"): + _make_key(options=_opts(include_path="/my/headers")) + + with pytest.raises(TypeError, match="extra_digest must be bytes"): + _make_key(options=_opts(include_path="/my/headers"), extra_digest=0) + + # A real digest is still the way through. + assert isinstance(_make_key(options=_opts(include_path="/my/headers"), extra_digest=b"\x01" * 32), bytes) + + @pytest.mark.parametrize( "code_type, code, target_type", [