From e1fe412676ed592404560aa1d8250298c0fcd27e Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sat, 8 Aug 2026 19:04:21 -0700 Subject: [PATCH] fix(core): require extra_digest to be bytes-like in make_program_cache_key Every guard around extra_digest asks only whether it ``is not None``: if extra_digest is None: external = [n for n in _EXTERNAL_CONTENT_OPTIONS if _option_is_set(options, n)] if external: raise ValueError("... refuses to build a key ... without an extra_digest ...") and the value is consumed with a bare coercion: if extra_digest is not None: _update("extra_digest", bytes(extra_digest)) So a non-bytes value satisfies the requirement to supply a digest without supplying one. ``extra_digest=0`` -- a plausible "no digest yet" sentinel, or the result of an int-returning hash helper -- is the worst shape, because ``bytes(0)`` is empty: make_program_cache_key(..., options=ProgramOptions(include_path="/my/headers"), extra_digest=0) # accepted key(extra_digest=0) == key(extra_digest=b"") # True The caller gets a persistent cache key that is completely blind to the header contents it was asked to fingerprint -- exactly the failure the ValueError exists to prevent. Every edit under /my/headers is then served a stale cubin. Related, from the same missing type check: key(extra_digest=5) == key(extra_digest=b"\x00" * 5) # True -- collision extra_digest=-1 -> ValueError: negative count extra_digest="abc" -> TypeError: string argument without an encoding extra_digest=1.5 -> TypeError: cannot convert 'float' object to bytes all surfacing from inside the hasher rather than from the argument check. Check the type up front, before any guard consults it. bytes, bytearray, memoryview, and None are accepted, as documented; anything else raises TypeError, matching how this module already reports a bad ``code`` or ``name_expressions`` element type. --- .../cuda/core/utils/_program_cache/_keys.py | 19 ++++++- cuda_core/docs/source/release/1.2.0-notes.rst | 7 +++ cuda_core/tests/test_program_cache.py | 54 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) 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", [