From fc3544e2c0cd7fc21a3dc973ab024e9949a9211b Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 18 Sep 2025 16:08:14 +0100 Subject: [PATCH 1/4] 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 c00815e2fd4c36..7c68cf0da67169 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 290e3e5678d0b40ec503be86862c67c164afdace Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 18 Sep 2025 16:30:10 +0100 Subject: [PATCH 2/4] 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 b99be073db5460..11acd4511ba5d2 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -2197,7 +2197,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; @@ -2206,10 +2221,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 bdc059ef119994083598b820f466784b96c8c607 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 18 Sep 2025 16:40:37 +0100 Subject: [PATCH 3/4] 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 00000000000000..4ed3cac4b0f41a --- /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 4e29a9578fc833fc7c9047047a90d440a8b80eed Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 27 Oct 2025 10:05:45 -0400 Subject: [PATCH 4/4] 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 11acd4511ba5d2..e816a37eea6365 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, @@ -2200,17 +2203,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); @@ -2224,20 +2234,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];