From 0d5d0b5789d48848a667b90f253fb8d165dec05e Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Fri, 6 Nov 2026 10:15:29 +0800 Subject: [PATCH 1/3] gh-142782: compare zoneinfo strong cache keys by string contents --- Lib/test/test_zoneinfo/test_zoneinfo.py | 80 +++++++++++++++++++ ...-11-06-00-00-00.gh-issue-142782.qU7nE4.rst | 2 + Modules/_zoneinfo.c | 8 +- 3 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index d4b3cd36e6d3293..aaa1c6538cfd4f4 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1723,6 +1723,21 @@ def __eq__(self, other): except CustomError: pass + def test_clear_cache_non_string_keys(self): + self.addCleanup(self.klass.clear_cache) + for cached in (False, True): + for key in (None, 1, b"America/Los_Angeles"): + with self.subTest(cached=cached, key=key): + self.klass.clear_cache() + original = ( + self.klass("America/Los_Angeles") if cached else None + ) + self.klass.clear_cache(only_keys=(key,)) + if cached: + self.assertIs( + self.klass("America/Los_Angeles"), original + ) + def test_weak_cache_descriptor_use_after_free(self): class BombDescriptor: def __get__(self, obj, owner): @@ -1747,6 +1762,71 @@ class EvilZoneInfo(self.klass): class CZoneInfoCacheTest(ZoneInfoCacheTest): module = c_zoneinfo + def test_strong_cache_path_like_key(self): + class Key: + def __fspath__(self): + return "UTC" + + def __len__(self): + return 3 + + def split(self, sep): + return ["UTC"] + + key = Key() + self.addCleanup(self.klass.clear_cache) + original = self.klass(key) + self.assertIs(self.klass(key), original) + self.assertEqual(self.klass("UTC").key, "UTC") + self.klass.clear_cache(only_keys=(key,)) + self.assertIsNot(self.klass(key), original) + + def test_strong_cache_str_subclass(self): + class Stringy(str): + def __eq__(self, other): + raise CustomError + + __hash__ = str.__hash__ + + self.addCleanup(self.klass.clear_cache) + for cached_type, lookup_type in ((str, Stringy), (Stringy, str), + (Stringy, Stringy)): + with self.subTest(cached_type=cached_type, lookup_type=lookup_type): + self.klass.clear_cache() + original = self.klass(cached_type("America/Los_Angeles")) + self.assertIs( + self.klass(lookup_type("America/Los_Angeles")), original + ) + + def test_strong_cache_reentrant_clear(self): + self._test_strong_cache_reentrant_clear("lookup") + + def test_strong_cache_reentrant_clear_only_keys(self): + self._test_strong_cache_reentrant_clear("clear") + + def _test_strong_cache_reentrant_clear(self, operation): + klass = self.klass + + class Stringy(str): + cleared = False + + def __eq__(self, other): + if not self.cleared: + self.cleared = True + klass.clear_cache(only_keys=(self,)) + return super().__eq__(other) + + __hash__ = str.__hash__ + + self.addCleanup(klass.clear_cache) + original = klass(Stringy("America/Los_Angeles")) + key = Stringy("America/Los_Angeles") + if operation == "lookup": + self.assertIs(klass(key), original) + else: + klass.clear_cache(only_keys=(key,)) + self.assertIsNot(klass("America/Los_Angeles"), original) + def test_inconsistent_weak_cache_get(self): class Cache: def get(self, key, default=None): diff --git a/Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst b/Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst new file mode 100644 index 000000000000000..8f6ea2085860c81 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst @@ -0,0 +1,2 @@ +Fix a use-after-free in :class:`zoneinfo.ZoneInfo` strong cache lookups with +:class:`str` subclasses. diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 45f65b9d30b6374..3a87440c63e8d24 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -2438,11 +2438,9 @@ find_in_strong_cache(const StrongCacheNode *const root, PyObject *const key) { const StrongCacheNode *node = root; while (node != NULL) { - int rv = PyObject_RichCompareBool(key, node->key, Py_EQ); - if (rv < 0) { - return NULL; - } - if (rv) { + if (key == node->key || + (PyUnicode_Check(key) && PyUnicode_Check(node->key) && + PyUnicode_Equal(key, node->key))) { return (StrongCacheNode *)node; } From 5364662bf151943a22a86a92e33199ed7c4c4c33 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Fri, 6 Nov 2026 15:28:19 +0800 Subject: [PATCH 2/3] gh-142782: avoid bytes in the cache test --- Lib/test/test_zoneinfo/test_zoneinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index aaa1c6538cfd4f4..131ad6d76ddf3d3 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1726,7 +1726,7 @@ def __eq__(self, other): def test_clear_cache_non_string_keys(self): self.addCleanup(self.klass.clear_cache) for cached in (False, True): - for key in (None, 1, b"America/Los_Angeles"): + for key in (None, 1): with self.subTest(cached=cached, key=key): self.klass.clear_cache() original = ( From 5e05e611b2a6f22450d4193feff41e06df09d25f Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Fri, 6 Nov 2026 16:14:30 +0000 Subject: [PATCH 3/3] Tweak blurb and tidy tests --- Lib/test/test_zoneinfo/test_zoneinfo.py | 25 ++++++------------- ...-11-06-00-00-00.gh-issue-142782.qU7nE4.rst | 5 ++-- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 131ad6d76ddf3d3..6656d7c1dc2cd67 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1724,19 +1724,11 @@ def __eq__(self, other): pass def test_clear_cache_non_string_keys(self): - self.addCleanup(self.klass.clear_cache) - for cached in (False, True): - for key in (None, 1): - with self.subTest(cached=cached, key=key): - self.klass.clear_cache() - original = ( - self.klass("America/Los_Angeles") if cached else None - ) - self.klass.clear_cache(only_keys=(key,)) - if cached: - self.assertIs( - self.klass("America/Los_Angeles"), original - ) + la0 = self.klass("America/Los_Angeles") + self.klass.clear_cache(only_keys=[None, 1]) + la1 = self.klass("America/Los_Angeles") + + self.assertIs(la0, la1) def test_weak_cache_descriptor_use_after_free(self): class BombDescriptor: @@ -1763,6 +1755,8 @@ class CZoneInfoCacheTest(ZoneInfoCacheTest): module = c_zoneinfo def test_strong_cache_path_like_key(self): + # A non-str key is matched by identity, and comparing it against a + # str key must not raise TypeError. class Key: def __fspath__(self): return "UTC" @@ -1774,10 +1768,9 @@ def split(self, sep): return ["UTC"] key = Key() - self.addCleanup(self.klass.clear_cache) original = self.klass(key) self.assertIs(self.klass(key), original) - self.assertEqual(self.klass("UTC").key, "UTC") + self.assertIsNot(self.klass("UTC"), original) self.klass.clear_cache(only_keys=(key,)) self.assertIsNot(self.klass(key), original) @@ -1788,7 +1781,6 @@ def __eq__(self, other): __hash__ = str.__hash__ - self.addCleanup(self.klass.clear_cache) for cached_type, lookup_type in ((str, Stringy), (Stringy, str), (Stringy, Stringy)): with self.subTest(cached_type=cached_type, lookup_type=lookup_type): @@ -1818,7 +1810,6 @@ def __eq__(self, other): __hash__ = str.__hash__ - self.addCleanup(klass.clear_cache) original = klass(Stringy("America/Los_Angeles")) key = Stringy("America/Los_Angeles") if operation == "lookup": diff --git a/Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst b/Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst index 8f6ea2085860c81..4bccc120ab8d33f 100644 --- a/Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst +++ b/Misc/NEWS.d/next/Library/2026-11-06-00-00-00.gh-issue-142782.qU7nE4.rst @@ -1,2 +1,3 @@ -Fix a use-after-free in :class:`zoneinfo.ZoneInfo` strong cache lookups with -:class:`str` subclasses. +Fix a use-after-free in :class:`zoneinfo.ZoneInfo` when a key's ``__eq__`` +clears the cache during a lookup. Strong cache keys are now compared by +string contents.