From 1f720b1e1e7fa7d7a27944aa77c58aa402ed0f66 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 18 Sep 2025 16:08:14 +0100 Subject: [PATCH 1/7] Add property-based tests for 'false friends' --- .../test_zoneinfo/test_zoneinfo_property.py | 130 +++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_zoneinfo/test_zoneinfo_property.py b/Lib/test/test_zoneinfo/test_zoneinfo_property.py index c00815e2fd4c364..7c68cf0da67169b 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo_property.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo_property.py @@ -4,9 +4,7 @@ import pickle import unittest import zoneinfo - from test.support.hypothesis_helper import hypothesis - import test.test_zoneinfo._support as test_support ZoneInfoTestBase = test_support.ZoneInfoTestBase @@ -61,6 +59,50 @@ def valid_key(key): return output +def _make_datetime_class(missing): + all_attrs = ( + "year", + "month", + "day", + "hour", + "minute", + "second", + "microsecond", + "tzinfo", + "fold", + ) + included_attrs = set(all_attrs) - set(missing) + + class ClassWithMissing: + def __init__(self, *args, **kwargs): + self._datetime = datetime.datetime(*args, **kwargs) + for attr, arg in zip(all_attrs, args): + if attr in kwargs: + raise ValueError( + f"{attr} specified more than once in argument list" + ) + + kwargs[attr] = arg + + for attr, arg in kwargs.items(): + if attr in included_attrs: + setattr(self, attr, arg) + + def utcoffset(self): + return self.tzinfo.utcoffset(self) + + def dst(self): + return self.tzinfo.dst(self) + + def tzname(self): + return self.tzinfo.tzname(self) + + def toordinal(self): + return self._datetime.toordinal() + + return ClassWithMissing + + VALID_KEYS = _valid_keys() if not VALID_KEYS: raise unittest.SkipTest("No time zone data available") @@ -127,6 +169,90 @@ def test_utc(self, dt): self.assertEqual(dt_zi.dst(), ZERO) self.assertEqual(dt_zi.tzname(), "UTC") + @hypothesis.given( + dt=hypothesis.strategies.datetimes(), + key=valid_keys(), + missing=hypothesis.strategies.lists( + hypothesis.strategies.sampled_from( + ( + "year", + "month", + "day", + "hour", + "minute", + "second", + "microsecond", + "tzinfo", + "fold", + ) + ), + unique=True, + min_size=1, + ), + ) + @hypothesis.example( + dt=datetime.datetime(1995, 9, 2, 14, 34, 56), + key="Europe/Berlin", + missing=["fold"], + ) + @hypothesis.example( + dt=datetime.datetime(1995, 9, 2, 14, 34, 56), + key="Europe/Berlin", + missing=["year"], + ) + @hypothesis.example( + dt=datetime.datetime(1995, 9, 2, 14, 34, 56), + key="Europe/Berlin", + missing=["tzinfo"], + ) + @hypothesis.example( + dt=datetime.datetime(1995, 9, 2, 14, 34, 56), + key="Europe/Berlin", + missing=[ + "year", + "month", + "day", + "hour", + "minute", + "second", + "microsecond", + "tzinfo", + "fold", + ], + ) + def test_bad_duck_typed_class(self, dt, key, missing): + # Passing duck typed `datetime`-lookalikes is not actively supported, + # but it also shouldn't raise segfaults. + + tzi = self.module.ZoneInfo(key) + DateTimeClass = _make_datetime_class(missing=missing) + false_friend = DateTimeClass( + dt.year, + dt.month, + dt.day, + dt.hour, + dt.minute, + dt.second, + dt.microsecond, + tzinfo=tzi, + fold=dt.fold, + ) + + try: + false_friend.utcoffset() + except Exception: + pass + + try: + false_friend.dst() + except Exception: + pass + + try: + false_friend.tzname() + except Exception: + pass + class CZoneInfoTest(ZoneInfoTest): module = c_zoneinfo From f366a8070378ed9c211580e3efa3a3d3a0318f90 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 18 Sep 2025 16:30:10 +0100 Subject: [PATCH 2/7] Prevent segfaults with zoneinfo and non-datetime --- Modules/_zoneinfo.c | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 46ab3499e003542..781a3febff62c1c 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -2235,7 +2235,22 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) return NULL; } - unsigned char fold = PyDateTime_DATE_GET_FOLD(dt); + unsigned char fold; + if (PyDateTime_Check(dt)) { + fold = PyDateTime_DATE_GET_FOLD(dt); + } else { + PyObject *fold_obj = PyObject_GetAttrString(dt, "fold"); + if (fold_obj == NULL) { + return NULL; + } + + fold = (unsigned char)PyLong_AsLong(fold_obj); + Py_DECREF(fold_obj); + if (PyErr_Occurred()) { + return NULL; + } + } + assert(fold < 2); int64_t *local_transitions = self->trans_list_wall[fold]; size_t num_trans = self->num_transitions; @@ -2244,10 +2259,23 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) return self->ttinfo_before; } else if (!num_trans || ts > local_transitions[self->num_transitions - 1]) { - return find_tzrule_ttinfo(&(self->tzrule_after), ts, fold, - PyDateTime_GET_YEAR(dt)); - } - else { + int year; + if (PyDateTime_Check(dt)) { + year = PyDateTime_GET_YEAR(dt); + } else { + PyObject *year_obj = PyObject_GetAttrString(dt, "year"); + if (year_obj == NULL) { + return NULL; + } + + year = PyLong_AsLong(year_obj); + Py_DECREF(year_obj); + if (PyErr_Occurred()) { + return NULL; + } + } + return find_tzrule_ttinfo(&(self->tzrule_after), ts, fold, year); + } else { size_t idx = _bisect(ts, local_transitions, self->num_transitions) - 1; assert(idx < self->num_transitions); return self->trans_ttinfos[idx]; From 0dc2320d01a7d885e0117349e6d426522687185b Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 18 Sep 2025 16:40:37 +0100 Subject: [PATCH 3/7] Add news --- .../Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst diff --git a/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst b/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst new file mode 100644 index 000000000000000..4ed3cac4b0f41ac --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst @@ -0,0 +1,3 @@ +Fixes a segmentation fault that would happen if :class:`zoneinfo.ZoneInfo` +were used with certain non-:class:`datetime.datetime` classes. Patch by Paul +Ganssle From f3a267b065ddf6b0ce64d58bc6b1f533dcfb9cde Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 27 Oct 2025 10:05:45 -0400 Subject: [PATCH 4/7] Address Serhiy's review comments --- Modules/_zoneinfo.c | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 781a3febff62c1c..47590fabfcbef12 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -123,6 +123,9 @@ static const int SOURCE_FILE = 2; static const size_t ZONEINFO_STRONG_CACHE_MAX_SIZE = 8; +static const int MINYEAR = 1; +static const int MAXYEAR = 9999; + // Forward declarations static int load_data(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, @@ -2238,17 +2241,24 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) unsigned char fold; if (PyDateTime_Check(dt)) { fold = PyDateTime_DATE_GET_FOLD(dt); - } else { + } + else { PyObject *fold_obj = PyObject_GetAttrString(dt, "fold"); if (fold_obj == NULL) { return NULL; } - fold = (unsigned char)PyLong_AsLong(fold_obj); + long fold_int = PyLong_AsLong(fold_obj); Py_DECREF(fold_obj); if (PyErr_Occurred()) { return NULL; } + if (fold_int < 0 || fold_int > 2) { + PyErr_Format(PyExc_ValueError, + "fold must be 0 or 1, got %d", fold_int); + return NULL; + } + fold = (unsigned char)fold_int; } assert(fold < 2); @@ -2262,20 +2272,32 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) int year; if (PyDateTime_Check(dt)) { year = PyDateTime_GET_YEAR(dt); - } else { + } + else { PyObject *year_obj = PyObject_GetAttrString(dt, "year"); if (year_obj == NULL) { return NULL; } - year = PyLong_AsLong(year_obj); + year = PyLong_AsInt(year_obj); Py_DECREF(year_obj); if (PyErr_Occurred()) { return NULL; } + + if (year < MINYEAR || year > 9999) { + PyErr_Format(PyExc_ValueError, + "year out of range, should be in (%d, %d) but got %d", + MINYEAR, + MAXYEAR, + year + ); + return NULL; + } } return find_tzrule_ttinfo(&(self->tzrule_after), ts, fold, year); - } else { + } + else { size_t idx = _bisect(ts, local_transitions, self->num_transitions) - 1; assert(idx < self->num_transitions); return self->trans_ttinfos[idx]; From f6c77dda502db0676b0e0d117ade48f04f69f39f Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 20 Oct 2026 14:25:19 -0400 Subject: [PATCH 5/7] gh-125318: Fix validation of zoneinfo false friends Reject fold=2 before it can reach the transition array, and use the correct format specifier for the long fold value. Match the datetime year error message and use MAXYEAR for the upper bound. Add regression tests for invalid folds and years, conversion errors, and valid boundary values. --- Lib/test/test_zoneinfo/test_zoneinfo.py | 47 +++++++++++++++++++++++++ Modules/_zoneinfo.c | 13 +++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 41ba92342ff2cbd..c16ff724b579c11 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -13,6 +13,7 @@ import shutil import struct import tempfile +import types import unittest from datetime import date, datetime, time, timedelta, timezone from functools import cached_property @@ -446,6 +447,52 @@ def __eq__(self, other): class CZoneInfoTest(ZoneInfoTest): module = c_zoneinfo + def test_duck_typed_invalid_values(self): + long_max = (1 << (struct.calcsize("l") * 8 - 1)) - 1 + cases = [ + ("fold", value, ValueError, f"fold must be 0 or 1, got {value}") + for value in (-1, 2, 3, 256, long_max, -long_max - 1) + ] + cases.extend( + ("year", value, ValueError, f"year must be in 1..9999, not {value}") + for value in (0, -1, 10000) + ) + cases.extend( + (attr, value, exc, message) + for attr in ("fold", "year") + for value, exc, message in ( + (1 << 100, OverflowError, ""), + (-(1 << 100), OverflowError, ""), + (None, TypeError, ""), + ) + ) + # Use a date beyond the last transition to exercise year validation. + dt = datetime(2040, 1, 1) + zi = self.zone_from_key("Europe/London") + for attr, value, exc, message in cases: + false_friend = types.SimpleNamespace( + year=dt.year, hour=0, minute=0, second=0, fold=0, + toordinal=dt.toordinal, + ) + setattr(false_friend, attr, value) + for method in (zi.utcoffset, zi.dst, zi.tzname): + with self.subTest(attr=attr, value=value, method=method): + with self.assertRaisesRegex(exc, re.escape(message)): + method(false_friend) + + def test_duck_typed_valid_boundaries(self): + zi = self.zone_from_key("Europe/London") + for year in (1, 9999): + for fold in (0, 1): + dt = datetime(year, 1, 1, fold=fold) + false_friend = types.SimpleNamespace( + year=year, hour=0, minute=0, second=0, fold=fold, + toordinal=dt.toordinal, + ) + for method in (zi.utcoffset, zi.dst, zi.tzname): + with self.subTest(year=year, fold=fold, method=method): + self.assertEqual(method(false_friend), method(dt)) + @unittest.skipIf(MISSING_C_DOCSTRINGS, "Signature information for builtins requires docstrings") def test_signatures(self): diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 47590fabfcbef12..890fefc259bd626 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -2253,9 +2253,9 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) if (PyErr_Occurred()) { return NULL; } - if (fold_int < 0 || fold_int > 2) { + if (fold_int < 0 || fold_int > 1) { PyErr_Format(PyExc_ValueError, - "fold must be 0 or 1, got %d", fold_int); + "fold must be 0 or 1, got %ld", fold_int); return NULL; } fold = (unsigned char)fold_int; @@ -2285,13 +2285,10 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) return NULL; } - if (year < MINYEAR || year > 9999) { + if (year < MINYEAR || year > MAXYEAR) { PyErr_Format(PyExc_ValueError, - "year out of range, should be in (%d, %d) but got %d", - MINYEAR, - MAXYEAR, - year - ); + "year must be in %d..%d, not %d", + MINYEAR, MAXYEAR, year); return NULL; } } From e2d209d534ed6f90eb252485e6153e3917b8ede8 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Fri, 23 Oct 2026 15:12:46 -0400 Subject: [PATCH 6/7] fixup! gh-125318: Fix validation of zoneinfo false friends --- Lib/test/test_zoneinfo/test_zoneinfo.py | 51 +++++++++++++++++++------ Lib/zoneinfo/_zoneinfo.py | 21 +++++++++- Modules/_zoneinfo.c | 6 +-- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index c16ff724b579c11..dd95dc777dd2ad9 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -443,19 +443,16 @@ def __eq__(self, other): self.assertFalse(key.eq_called) self.assertIs(tz2, tz1) - -class CZoneInfoTest(ZoneInfoTest): - module = c_zoneinfo - def test_duck_typed_invalid_values(self): long_max = (1 << (struct.calcsize("l") * 8 - 1)) - 1 + int_max = (1 << (struct.calcsize("i") * 8 - 1)) - 1 cases = [ - ("fold", value, ValueError, f"fold must be 0 or 1, got {value}") + ("fold", value, ValueError, f"fold must be 0 or 1, not {value}") for value in (-1, 2, 3, 256, long_max, -long_max - 1) ] cases.extend( ("year", value, ValueError, f"year must be in 1..9999, not {value}") - for value in (0, -1, 10000) + for value in (0, -1, 10000, int_max, -int_max - 1) ) cases.extend( (attr, value, exc, message) @@ -464,21 +461,29 @@ def test_duck_typed_invalid_values(self): (1 << 100, OverflowError, ""), (-(1 << 100), OverflowError, ""), (None, TypeError, ""), + (1.0, TypeError, ""), ) ) + cases.extend( + (attr, value, OverflowError, "") + for attr, maximum in (("fold", long_max), ("year", int_max)) + for value in (maximum + 1, -maximum - 2) + ) # Use a date beyond the last transition to exercise year validation. dt = datetime(2040, 1, 1) - zi = self.zone_from_key("Europe/London") for attr, value, exc, message in cases: false_friend = types.SimpleNamespace( year=dt.year, hour=0, minute=0, second=0, fold=0, toordinal=dt.toordinal, ) setattr(false_friend, attr, value) - for method in (zi.utcoffset, zi.dst, zi.tzname): - with self.subTest(attr=attr, value=value, method=method): - with self.assertRaisesRegex(exc, re.escape(message)): - method(false_friend) + for key in ("Europe/London", "UTC"): + zi = self.zone_from_key(key) + for method in (zi.utcoffset, zi.dst, zi.tzname): + with self.subTest(attr=attr, value=value, key=key, + method=method): + with self.assertRaisesRegex(exc, re.escape(message)): + method(false_friend) def test_duck_typed_valid_boundaries(self): zi = self.zone_from_key("Europe/London") @@ -493,6 +498,30 @@ def test_duck_typed_valid_boundaries(self): with self.subTest(year=year, fold=fold, method=method): self.assertEqual(method(false_friend), method(dt)) + def test_duck_typed_index(self): + class Index: + def __init__(self, value): + self.value = value + + def __index__(self): + return self.value + + zi = self.zone_from_key("Europe/London") + for fold in (0, 1): + # An ambiguous time beyond the last explicit transition. + dt = datetime(2040, 10, 28, 1, 30, fold=fold) + false_friend = types.SimpleNamespace( + year=Index(dt.year), hour=dt.hour, minute=dt.minute, + second=dt.second, fold=Index(fold), toordinal=dt.toordinal, + ) + for method in (zi.utcoffset, zi.dst, zi.tzname): + with self.subTest(fold=fold, method=method): + self.assertEqual(method(false_friend), method(dt)) + + +class CZoneInfoTest(ZoneInfoTest): + module = c_zoneinfo + @unittest.skipIf(MISSING_C_DOCSTRINGS, "Signature information for builtins requires docstrings") def test_signatures(self): diff --git a/Lib/zoneinfo/_zoneinfo.py b/Lib/zoneinfo/_zoneinfo.py index 48154775568b6dc..97dd2e0a3b15a5e 100644 --- a/Lib/zoneinfo/_zoneinfo.py +++ b/Lib/zoneinfo/_zoneinfo.py @@ -2,7 +2,9 @@ import calendar import collections import functools +import operator import re +import struct import weakref from datetime import datetime, timedelta, tzinfo @@ -11,6 +13,10 @@ EPOCH = datetime(1970, 1, 1) EPOCHORDINAL = datetime(1970, 1, 1).toordinal() +# Match the integer conversions in the C implementation. +_LONG_MAX = (1 << (struct.calcsize("l") * 8 - 1)) - 1 +_INT_MAX = (1 << (struct.calcsize("i") * 8 - 1)) - 1 + # It is relatively expensive to construct new timedelta objects, and in most # cases we're looking at the same deltas, like integer numbers of hours, etc. # To improve speed and memory use, we'll keep a dictionary with references @@ -168,15 +174,26 @@ def _find_trans(self, dt): ts = self._get_local_timestamp(dt) - lt = self._trans_local[dt.fold] + fold = operator.index(dt.fold) + if not -_LONG_MAX - 1 <= fold <= _LONG_MAX: + raise OverflowError("Python int too large to convert to C long") + if not 0 <= fold <= 1: + raise ValueError(f"fold must be 0 or 1, not {fold}") + + lt = self._trans_local[fold] num_trans = len(lt) if num_trans and ts < lt[0]: return self._tti_before elif not num_trans or ts > lt[-1]: + year = operator.index(dt.year) + if not -_INT_MAX - 1 <= year <= _INT_MAX: + raise OverflowError("Python int too large to convert to C int") + if not 1 <= year <= 9999: + raise ValueError(f"year must be in 1..9999, not {year}") if isinstance(self._tz_after, _TZStr): - return self._tz_after.get_trans_info(ts, dt.year, dt.fold) + return self._tz_after.get_trans_info(ts, year, fold) else: return self._tz_after else: diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 890fefc259bd626..45b0b2487ea079f 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -2250,12 +2250,12 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) long fold_int = PyLong_AsLong(fold_obj); Py_DECREF(fold_obj); - if (PyErr_Occurred()) { + if (fold_int == -1 && PyErr_Occurred()) { return NULL; } if (fold_int < 0 || fold_int > 1) { PyErr_Format(PyExc_ValueError, - "fold must be 0 or 1, got %ld", fold_int); + "fold must be 0 or 1, not %ld", fold_int); return NULL; } fold = (unsigned char)fold_int; @@ -2281,7 +2281,7 @@ find_ttinfo(zoneinfo_state *state, PyZoneInfo_ZoneInfo *self, PyObject *dt) year = PyLong_AsInt(year_obj); Py_DECREF(year_obj); - if (PyErr_Occurred()) { + if (year == -1 && PyErr_Occurred()) { return NULL; } From bb07c5382962274d92d5b777ac319e73e6602d5b Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Fri, 23 Oct 2026 15:12:46 -0400 Subject: [PATCH 7/7] fixup! Add news --- .../Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst b/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst index 4ed3cac4b0f41ac..a4a8b0f0743e971 100644 --- a/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst +++ b/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst @@ -1,3 +1,6 @@ Fixes a segmentation fault that would happen if :class:`zoneinfo.ZoneInfo` were used with certain non-:class:`datetime.datetime` classes. Patch by Paul -Ganssle +Ganssle. + +The pure-Python implementation now validates the ``fold`` and ``year`` +attributes of these objects consistently with the C implementation.