Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions Lib/test/test_zoneinfo/test_zoneinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
130 changes: 128 additions & 2 deletions Lib/test/test_zoneinfo/test_zoneinfo_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions Lib/zoneinfo/_zoneinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import calendar
import collections
import functools
import operator
import re
import struct
import weakref
from datetime import datetime, timedelta, tzinfo

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 50 additions & 3 deletions Modules/_zoneinfo.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Comment thread
serhiy-storchaka marked this conversation as resolved.
}

assert(fold < 2);
int64_t *local_transitions = self->trans_list_wall[fold];
size_t num_trans = self->num_transitions;
Expand All @@ -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;
Expand Down
Loading