diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 41ba92342ff2cbd..dd95dc777dd2ad9 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 @@ -442,6 +443,81 @@ def __eq__(self, other): self.assertFalse(key.eq_called) self.assertIs(tz2, tz1) + 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, 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, int_max, -int_max - 1) + ) + cases.extend( + (attr, value, exc, message) + for attr in ("fold", "year") + for value, exc, message in ( + (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) + 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 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") + 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)) + + 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 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 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/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..a4a8b0f0743e971 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-18-16-40-18.gh-issue-125318.JzZysW.rst @@ -0,0 +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. + +The pure-Python implementation now validates the ``fold`` and ``year`` +attributes of these objects consistently with the C implementation. diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 46ab3499e003542..45b0b2487ea079f 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, @@ -2235,7 +2238,29 @@ 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; + } + + long fold_int = PyLong_AsLong(fold_obj); + Py_DECREF(fold_obj); + 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, not %ld", fold_int); + return NULL; + } + fold = (unsigned char)fold_int; + } + assert(fold < 2); int64_t *local_transitions = self->trans_list_wall[fold]; size_t num_trans = self->num_transitions; @@ -2244,8 +2269,30 @@ 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)); + 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_AsInt(year_obj); + Py_DECREF(year_obj); + if (year == -1 && PyErr_Occurred()) { + return NULL; + } + + if (year < MINYEAR || year > MAXYEAR) { + PyErr_Format(PyExc_ValueError, + "year must be in %d..%d, not %d", + MINYEAR, MAXYEAR, year); + return NULL; + } + } + return find_tzrule_ttinfo(&(self->tzrule_after), ts, fold, year); } else { size_t idx = _bisect(ts, local_transitions, self->num_transitions) - 1;