From fe106a22e03d2575b00e9a3c04abc35a102ad754 Mon Sep 17 00:00:00 2001 From: shams Date: Thu, 29 Oct 2026 12:34:58 +0530 Subject: [PATCH 1/4] cap transitions of a VTIMEZONE RRULE Timezones created from a VTIMEZONE expand its RRULE from DTSTART: the pytz provider lists every transition up to 2038 while parsing, and dateutil's tzical walks all transitions before a date when the zoneinfo provider computes an offset. A VTIMEZONE with RRULE:FREQ=MINUTELY took 36 s and 1.6 GB for one utcoffset() call, FREQ=SECONDLY exhausts memory. Reject an RRULE with more than MAX_TIMEZONE_TRANSITIONS_PER_YEAR (100) transitions in the year after its first transition, mirroring MAX_ALARM_REPEAT. -1 disables the cap. AI use: Claude Opus 5.5 (claude-opus-5-5). Used to search the code base for unbounded expansion of untrusted input, measure the VTIMEZONE RRULE case, and draft the fix, tests and change log entry, which I reviewed. Output: (this commit) --- news/+vtimezone-rrule-transitions.bugfix | 1 + src/icalendar/cal/timezone.py | 47 ++++++++++++ src/icalendar/config.py | 12 +++ .../tests/test_vtimezone_rrule_transitions.py | 76 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 news/+vtimezone-rrule-transitions.bugfix create mode 100644 src/icalendar/tests/test_vtimezone_rrule_transitions.py diff --git a/news/+vtimezone-rrule-transitions.bugfix b/news/+vtimezone-rrule-transitions.bugfix new file mode 100644 index 000000000..ec6cf0921 --- /dev/null +++ b/news/+vtimezone-rrule-transitions.bugfix @@ -0,0 +1 @@ +Reject a ``VTIMEZONE`` whose ``RRULE`` produces more than :data:`~icalendar.config.MAX_TIMEZONE_TRANSITIONS_PER_YEAR` transitions within a year, ``100`` by default. Timezones created from a ``VTIMEZONE`` expand its ``RRULE`` from ``DTSTART``, so a calendar with an ``RRULE`` such as ``FREQ=SECONDLY`` in a ``VTIMEZONE`` could exhaust memory and CPU when it was parsed or when a date in that timezone was used. I used AI to assist me with this change. @shamsManna diff --git a/src/icalendar/cal/timezone.py b/src/icalendar/cal/timezone.py index 65881e264..25790acf0 100644 --- a/src/icalendar/cal/timezone.py +++ b/src/icalendar/cal/timezone.py @@ -4,10 +4,12 @@ from collections import defaultdict from datetime import date, datetime, timedelta, tzinfo +from itertools import islice, takewhile import dateutil.rrule import dateutil.tz +from icalendar import config from icalendar.attr import ( create_single_property, exdates_property, @@ -131,6 +133,49 @@ def _extract_offsets(component: TimezoneDaylight | TimezoneStandard, tzname: str is_dst = 1 return is_dst, transitions + def _check_transitions(self) -> None: + """Check that the RRULEs of the timezone components are not too frequent. + + Raises: + ValueError: If an ``RRULE`` produces more than + :data:`~icalendar.config.MAX_TIMEZONE_TRANSITIONS_PER_YEAR` + transitions within a year after its first transition. + """ + limit = config.MAX_TIMEZONE_TRANSITIONS_PER_YEAR + if limit < 0: + return + for component in self.walk(): + if isinstance(component, Timezone) or "DTSTART" not in component: + continue + ical_rrules = component.get("RRULE", []) + if not isinstance(ical_rrules, list): + ical_rrules = [ical_rrules] + for ical_rrule in ical_rrules: + try: + rrule = dateutil.rrule.rrulestr( + ical_rrule.to_ical().decode("utf-8"), + dtstart=to_datetime(component.DTSTART).replace(tzinfo=None), + ignoretz=True, + ) + first = next(iter(rrule), None) + except ValueError: + # The timezone provider reports invalid rules. + continue + if first is None: + continue + # A period of a rule is at most a year long, so the year after + # the first transition shows how many transitions a year it has. + try: + end = first + timedelta(days=400) + except OverflowError: + end = datetime.max # noqa: DTZ901, the rule is floating + year = takewhile(lambda dt, end=end: dt < end, rrule) + if len(list(islice(year, limit + 1))) > limit: + raise ValueError( + f"Invalid iCalendar VTIMEZONE: RRULE {ical_rrule.to_ical().decode()}" + f" produces more than {limit} transitions within a year." + ) + @staticmethod def _make_unique_tzname(tzname, tznames): """ @@ -157,6 +202,7 @@ def to_tz(self, tzp: TZP = tzp, lookup_tzid: bool = True): tz = tzp.timezone(self.tz_name) if tz is not None: return tz + self._check_transitions() return tzp.create_timezone(self) @property @@ -180,6 +226,7 @@ def get_transitions( - transition_info = [(TZOFFSETTO, dts_offset, tzname)] """ + self._check_transitions() zone = self.tz_name transitions = [] dst = {} diff --git a/src/icalendar/config.py b/src/icalendar/config.py index 86ed19882..5690afe95 100644 --- a/src/icalendar/config.py +++ b/src/icalendar/config.py @@ -17,6 +17,17 @@ .. versionadded:: 7.2.1 """ +MAX_TIMEZONE_TRANSITIONS_PER_YEAR: int = 100 +"""Cap on transitions a ``VTIMEZONE`` ``RRULE`` may produce within a year. + +A ``STANDARD`` or ``DAYLIGHT`` component of a ``VTIMEZONE`` usually repeats yearly. +Timezones created from a ``VTIMEZONE`` expand its ``RRULE`` from ``DTSTART``, so +an ``RRULE`` such as ``FREQ=SECONDLY`` can exhaust memory or CPU when a date +in this timezone is used. +A ``VTIMEZONE`` whose ``RRULE`` produces more transitions within a year is invalid. +Set to ``-1`` to disable the cap, which should be done only for fully trusted input. +""" + def _clamp_repeat(n: int) -> int: """Return *n* clamped to ``[0, MAX_ALARM_REPEAT]``. @@ -62,5 +73,6 @@ def parse_xml(source) -> ElementTree[Element[str]]: __all__ = [ "MAX_ALARM_REPEAT", + "MAX_TIMEZONE_TRANSITIONS_PER_YEAR", "parse_xml", ] diff --git a/src/icalendar/tests/test_vtimezone_rrule_transitions.py b/src/icalendar/tests/test_vtimezone_rrule_transitions.py new file mode 100644 index 000000000..192295ef2 --- /dev/null +++ b/src/icalendar/tests/test_vtimezone_rrule_transitions.py @@ -0,0 +1,76 @@ +"""A VTIMEZONE RRULE with too many transitions is invalid. + +Timezones created from a VTIMEZONE expand its RRULE from DTSTART. +A frequent RRULE could exhaust memory or CPU. +""" + +from datetime import datetime + +import pytest + +import icalendar.config as cfg +from icalendar import Calendar, Timezone + +VTIMEZONE = """BEGIN:VTIMEZONE +TZID:{tzid} +BEGIN:STANDARD +DTSTART:20370101T000000 +RRULE:{rrule} +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20370601T000000 +RRULE:FREQ=YEARLY +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +END:VTIMEZONE +""" + +CALENDAR = """BEGIN:VCALENDAR +{vtimezone}BEGIN:VEVENT +UID:transitions +DTSTART;TZID={tzid}:20370701T120000 +END:VEVENT +END:VCALENDAR +""" + + +def calendar(tzid: str, rrule: str) -> str: + vtimezone = VTIMEZONE.format(tzid=tzid, rrule=rrule) + return CALENDAR.format(vtimezone=vtimezone, tzid=tzid).replace("\n", "\r\n") + + +@pytest.mark.parametrize( + "rrule", + [ + "FREQ=SECONDLY", + "FREQ=MINUTELY", + "FREQ=HOURLY", + "FREQ=DAILY", + "FREQ=YEARLY;BYMONTH=1,2,3,4,5,6,7,8,9,10,11,12;BYHOUR=0,1,2,3,4,5,6,7,8,9", + ], +) +def test_frequent_rrule_is_invalid(tzp, rrule): + """Parsing a VTIMEZONE with too many transitions fails fast.""" + tzid = f"transitions-{tzp.name}-{rrule}" + with pytest.raises(ValueError, match="transitions within a year"): + Calendar.from_ical(calendar(tzid, rrule)) + + +@pytest.mark.parametrize("rrule", ["FREQ=YEARLY", "FREQ=MONTHLY", "FREQ=WEEKLY"]) +def test_rrule_within_limit_is_used(tzp, rrule): + """Timezones with few transitions a year still work.""" + tzid = f"transitions-{tzp.name}-{rrule}" + event = Calendar.from_ical(calendar(tzid, rrule)).events[0] + assert event.DTSTART == datetime(2037, 7, 1, 12, tzinfo=event.DTSTART.tzinfo) + assert event.DTSTART.tzinfo is not None + + +def test_limit_can_be_disabled(tzp, monkeypatch): + """Trusted input can use more transitions.""" + monkeypatch.setattr(cfg, "MAX_TIMEZONE_TRANSITIONS_PER_YEAR", -1) + vtimezone = VTIMEZONE.format(tzid="transitions-disabled", rrule="FREQ=DAILY") + timezone = Timezone.from_ical(vtimezone.replace("\n", "\r\n")) + assert timezone.to_tz(tzp, lookup_tzid=False) is not None From 9e4e42fbecb8f353f1b9c0659d52b0bab3e49f94 Mon Sep 17 00:00:00 2001 From: shams Date: Fri, 6 Nov 2026 13:46:21 +0530 Subject: [PATCH 2/4] drop the lambda in the transition count and test the PR example --- src/icalendar/cal/timezone.py | 8 +++-- .../tests/test_vtimezone_rrule_transitions.py | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/icalendar/cal/timezone.py b/src/icalendar/cal/timezone.py index 25790acf0..868677e3e 100644 --- a/src/icalendar/cal/timezone.py +++ b/src/icalendar/cal/timezone.py @@ -4,7 +4,7 @@ from collections import defaultdict from datetime import date, datetime, timedelta, tzinfo -from itertools import islice, takewhile +from itertools import islice import dateutil.rrule import dateutil.tz @@ -169,8 +169,10 @@ def _check_transitions(self) -> None: end = first + timedelta(days=400) except OverflowError: end = datetime.max # noqa: DTZ901, the rule is floating - year = takewhile(lambda dt, end=end: dt < end, rrule) - if len(list(islice(year, limit + 1))) > limit: + # The transitions are sorted, so there are more than limit + # transitions in that year if the one after the limit is in it. + first_transitions = list(islice(rrule, limit + 1)) + if len(first_transitions) > limit and first_transitions[limit] < end: raise ValueError( f"Invalid iCalendar VTIMEZONE: RRULE {ical_rrule.to_ical().decode()}" f" produces more than {limit} transitions within a year." diff --git a/src/icalendar/tests/test_vtimezone_rrule_transitions.py b/src/icalendar/tests/test_vtimezone_rrule_transitions.py index 192295ef2..56f987bb7 100644 --- a/src/icalendar/tests/test_vtimezone_rrule_transitions.py +++ b/src/icalendar/tests/test_vtimezone_rrule_transitions.py @@ -74,3 +74,36 @@ def test_limit_can_be_disabled(tzp, monkeypatch): vtimezone = VTIMEZONE.format(tzid="transitions-disabled", rrule="FREQ=DAILY") timezone = Timezone.from_ical(vtimezone.replace("\n", "\r\n")) assert timezone.to_tz(tzp, lookup_tzid=False) is not None + + +PR_EXAMPLE = """BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:Custom +BEGIN:STANDARD +DTSTART:19700101T000000 +RRULE:FREQ=SECONDLY +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700601T000000 +RRULE:FREQ=YEARLY +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:a +DTSTART;TZID=Custom:20250101T120000 +END:VEVENT +END:VCALENDAR +""".replace("\n", "\r\n") + + +def test_secondly_rrule_from_1970_is_invalid(tzp): + """Reading the offset of an event in a SECONDLY timezone fails fast. + + Without the limit, this exhausts memory. + """ + with pytest.raises(ValueError, match="transitions within a year"): + Calendar.from_ical(PR_EXAMPLE).events[0].DTSTART.utcoffset() From 98233dc2b0208012d0a74c9608d231b57b2bf33a Mon Sep 17 00:00:00 2001 From: shams Date: Fri, 6 Nov 2026 19:31:51 +0530 Subject: [PATCH 3/4] go back to takewhile and make the transition tests fail fast without the limit --- src/icalendar/cal/timezone.py | 8 +++---- .../tests/test_vtimezone_rrule_transitions.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/icalendar/cal/timezone.py b/src/icalendar/cal/timezone.py index 868677e3e..8ed90f36e 100644 --- a/src/icalendar/cal/timezone.py +++ b/src/icalendar/cal/timezone.py @@ -4,7 +4,7 @@ from collections import defaultdict from datetime import date, datetime, timedelta, tzinfo -from itertools import islice +from itertools import islice, takewhile import dateutil.rrule import dateutil.tz @@ -169,10 +169,8 @@ def _check_transitions(self) -> None: end = first + timedelta(days=400) except OverflowError: end = datetime.max # noqa: DTZ901, the rule is floating - # The transitions are sorted, so there are more than limit - # transitions in that year if the one after the limit is in it. - first_transitions = list(islice(rrule, limit + 1)) - if len(first_transitions) > limit and first_transitions[limit] < end: + transitions_in_year = takewhile(lambda dt, end=end: dt < end, rrule) + if len(list(islice(transitions_in_year, limit + 1))) > limit: raise ValueError( f"Invalid iCalendar VTIMEZONE: RRULE {ical_rrule.to_ical().decode()}" f" produces more than {limit} transitions within a year." diff --git a/src/icalendar/tests/test_vtimezone_rrule_transitions.py b/src/icalendar/tests/test_vtimezone_rrule_transitions.py index 56f987bb7..0192e7f1b 100644 --- a/src/icalendar/tests/test_vtimezone_rrule_transitions.py +++ b/src/icalendar/tests/test_vtimezone_rrule_transitions.py @@ -6,11 +6,32 @@ from datetime import datetime +import dateutil.rrule import pytest import icalendar.config as cfg from icalendar import Calendar, Timezone +# Far more than any test here needs, small enough to stop in seconds. +MAX_EXPANDED_TRANSITIONS = 10_000 + + +@pytest.fixture(autouse=True) +def bounded_rrule_expansion(monkeypatch): + """Fail fast instead of hanging if an RRULE is expanded without a limit.""" + expand = dateutil.rrule.rrule._iter + + def bounded_expand(self): + for count, dt in enumerate(expand(self)): + if count >= MAX_EXPANDED_TRANSITIONS: + pytest.fail( + f"RRULE expanded to more than {MAX_EXPANDED_TRANSITIONS} transitions" + ) + yield dt + + monkeypatch.setattr(dateutil.rrule.rrule, "_iter", bounded_expand) + + VTIMEZONE = """BEGIN:VTIMEZONE TZID:{tzid} BEGIN:STANDARD From 3b9851a60803430aee1e34131759c7b266cc12c1 Mon Sep 17 00:00:00 2001 From: shams Date: Fri, 13 Nov 2026 14:50:22 +0530 Subject: [PATCH 4/4] only allow FREQ=YEARLY in a VTIMEZONE RRULE Replace the per-year transition count and MAX_TIMEZONE_TRANSITIONS_PER_YEAR with a check that every RRULE of a STANDARD or DAYLIGHT component has FREQ=YEARLY. Move the test calendar into .ics, .jcal and .xml files and check the timezone is rejected in all three formats. --- news/+vtimezone-rrule-transitions.bugfix | 2 +- src/icalendar/cal/timezone.py | 47 ++----- src/icalendar/config.py | 12 -- .../pr_2047_vtimezone_secondly_rrule_ical.ics | 21 +++ ...pr_2047_vtimezone_secondly_rrule_jcal.jcal | 37 +++++ .../pr_2047_vtimezone_secondly_rrule_xcal.xml | 67 +++++++++ src/icalendar/tests/conftest.py | 3 + .../tests/test_vtimezone_rrule_transitions.py | 133 +++++------------- 8 files changed, 179 insertions(+), 143 deletions(-) create mode 100644 src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_ical.ics create mode 100644 src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_jcal.jcal create mode 100644 src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_xcal.xml diff --git a/news/+vtimezone-rrule-transitions.bugfix b/news/+vtimezone-rrule-transitions.bugfix index ec6cf0921..c2385b1a9 100644 --- a/news/+vtimezone-rrule-transitions.bugfix +++ b/news/+vtimezone-rrule-transitions.bugfix @@ -1 +1 @@ -Reject a ``VTIMEZONE`` whose ``RRULE`` produces more than :data:`~icalendar.config.MAX_TIMEZONE_TRANSITIONS_PER_YEAR` transitions within a year, ``100`` by default. Timezones created from a ``VTIMEZONE`` expand its ``RRULE`` from ``DTSTART``, so a calendar with an ``RRULE`` such as ``FREQ=SECONDLY`` in a ``VTIMEZONE`` could exhaust memory and CPU when it was parsed or when a date in that timezone was used. I used AI to assist me with this change. @shamsManna +Reject a VTIMEZONE whose RRULE does not have ``FREQ=YEARLY``. Timezones created from a VTIMEZONE expand its RRULE from DTSTART, so a calendar with an RRULE such as ``FREQ=SECONDLY`` in a VTIMEZONE could exhaust memory and CPU when it was parsed or when a date in that timezone was used. I used AI to assist me with this change. @shamsManna diff --git a/src/icalendar/cal/timezone.py b/src/icalendar/cal/timezone.py index 8ed90f36e..c1c5c5d17 100644 --- a/src/icalendar/cal/timezone.py +++ b/src/icalendar/cal/timezone.py @@ -4,12 +4,10 @@ from collections import defaultdict from datetime import date, datetime, timedelta, tzinfo -from itertools import islice, takewhile import dateutil.rrule import dateutil.tz -from icalendar import config from icalendar.attr import ( create_single_property, exdates_property, @@ -133,47 +131,26 @@ def _extract_offsets(component: TimezoneDaylight | TimezoneStandard, tzname: str is_dst = 1 return is_dst, transitions - def _check_transitions(self) -> None: - """Check that the RRULEs of the timezone components are not too frequent. + def _check_rrule_frequency(self) -> None: + """Check that the timezone components only repeat yearly. + + Timezones created from a VTIMEZONE expand its RRULE from DTSTART, + so an RRULE such as ``FREQ=SECONDLY`` could exhaust memory and CPU. Raises: - ValueError: If an ``RRULE`` produces more than - :data:`~icalendar.config.MAX_TIMEZONE_TRANSITIONS_PER_YEAR` - transitions within a year after its first transition. + ValueError: If an RRULE has a frequency other than ``YEARLY``. """ - limit = config.MAX_TIMEZONE_TRANSITIONS_PER_YEAR - if limit < 0: - return for component in self.walk(): - if isinstance(component, Timezone) or "DTSTART" not in component: + if isinstance(component, Timezone): continue ical_rrules = component.get("RRULE", []) if not isinstance(ical_rrules, list): ical_rrules = [ical_rrules] for ical_rrule in ical_rrules: - try: - rrule = dateutil.rrule.rrulestr( - ical_rrule.to_ical().decode("utf-8"), - dtstart=to_datetime(component.DTSTART).replace(tzinfo=None), - ignoretz=True, - ) - first = next(iter(rrule), None) - except ValueError: - # The timezone provider reports invalid rules. - continue - if first is None: - continue - # A period of a rule is at most a year long, so the year after - # the first transition shows how many transitions a year it has. - try: - end = first + timedelta(days=400) - except OverflowError: - end = datetime.max # noqa: DTZ901, the rule is floating - transitions_in_year = takewhile(lambda dt, end=end: dt < end, rrule) - if len(list(islice(transitions_in_year, limit + 1))) > limit: + if ical_rrule.get("FREQ") != ["YEARLY"]: raise ValueError( - f"Invalid iCalendar VTIMEZONE: RRULE {ical_rrule.to_ical().decode()}" - f" produces more than {limit} transitions within a year." + "Invalid iCalendar VTIMEZONE: expected an RRULE with" + f" FREQ=YEARLY, got {ical_rrule.to_ical().decode()!r}." ) @staticmethod @@ -202,7 +179,7 @@ def to_tz(self, tzp: TZP = tzp, lookup_tzid: bool = True): tz = tzp.timezone(self.tz_name) if tz is not None: return tz - self._check_transitions() + self._check_rrule_frequency() return tzp.create_timezone(self) @property @@ -226,7 +203,7 @@ def get_transitions( - transition_info = [(TZOFFSETTO, dts_offset, tzname)] """ - self._check_transitions() + self._check_rrule_frequency() zone = self.tz_name transitions = [] dst = {} diff --git a/src/icalendar/config.py b/src/icalendar/config.py index 5690afe95..86ed19882 100644 --- a/src/icalendar/config.py +++ b/src/icalendar/config.py @@ -17,17 +17,6 @@ .. versionadded:: 7.2.1 """ -MAX_TIMEZONE_TRANSITIONS_PER_YEAR: int = 100 -"""Cap on transitions a ``VTIMEZONE`` ``RRULE`` may produce within a year. - -A ``STANDARD`` or ``DAYLIGHT`` component of a ``VTIMEZONE`` usually repeats yearly. -Timezones created from a ``VTIMEZONE`` expand its ``RRULE`` from ``DTSTART``, so -an ``RRULE`` such as ``FREQ=SECONDLY`` can exhaust memory or CPU when a date -in this timezone is used. -A ``VTIMEZONE`` whose ``RRULE`` produces more transitions within a year is invalid. -Set to ``-1`` to disable the cap, which should be done only for fully trusted input. -""" - def _clamp_repeat(n: int) -> int: """Return *n* clamped to ``[0, MAX_ALARM_REPEAT]``. @@ -73,6 +62,5 @@ def parse_xml(source) -> ElementTree[Element[str]]: __all__ = [ "MAX_ALARM_REPEAT", - "MAX_TIMEZONE_TRANSITIONS_PER_YEAR", "parse_xml", ] diff --git a/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_ical.ics b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_ical.ics new file mode 100644 index 000000000..750ca7691 --- /dev/null +++ b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_ical.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:Custom +BEGIN:STANDARD +DTSTART:19700101T000000 +RRULE:FREQ=SECONDLY +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700601T000000 +RRULE:FREQ=YEARLY +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:a +DTSTART;TZID=Custom:20250101T120000 +END:VEVENT +END:VCALENDAR diff --git a/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_jcal.jcal b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_jcal.jcal new file mode 100644 index 000000000..eef8fd787 --- /dev/null +++ b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_jcal.jcal @@ -0,0 +1,37 @@ +["vcalendar", + [], + [ + ["vtimezone", + [ + ["tzid", {}, "text", "Custom"] + ], + [ + ["standard", + [ + ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ["rrule", {}, "recur", {"freq": "SECONDLY"}], + ["tzoffsetfrom", {}, "utc-offset", "+01:00"], + ["tzoffsetto", {}, "utc-offset", "+00:00"] + ], + [] + ], + ["daylight", + [ + ["dtstart", {}, "date-time", "1970-06-01T00:00:00"], + ["rrule", {}, "recur", {"freq": "YEARLY"}], + ["tzoffsetfrom", {}, "utc-offset", "+00:00"], + ["tzoffsetto", {}, "utc-offset", "+01:00"] + ], + [] + ] + ] + ], + ["vevent", + [ + ["uid", {}, "text", "a"], + ["dtstart", {"tzid": "Custom"}, "date-time", "2025-01-01T12:00:00"] + ], + [] + ] + ] +] diff --git a/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_xcal.xml b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_xcal.xml new file mode 100644 index 000000000..3af51ec48 --- /dev/null +++ b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_xcal.xml @@ -0,0 +1,67 @@ + + + + + + + + Custom + + + + + + + 1970-01-01T00:00:00 + + + + SECONDLY + + + + +01:00 + + + +00:00 + + + + + + + 1970-06-01T00:00:00 + + + + YEARLY + + + + +00:00 + + + +01:00 + + + + + + + + + a + + + + + Custom + + + 2025-01-01T12:00:00 + + + + + + diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 98cf1875a..447c5bd0c 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -243,6 +243,9 @@ def in_timezone(request, tzp): "pr_480_summary_with_colon.ics", "parsing_error_in_UTC_offset.ics", "parsing_error.ics", + "pr_2047_vtimezone_secondly_rrule_ical.ics", + "pr_2047_vtimezone_secondly_rrule_jcal.jcal", + "pr_2047_vtimezone_secondly_rrule_xcal.xml", ) diff --git a/src/icalendar/tests/test_vtimezone_rrule_transitions.py b/src/icalendar/tests/test_vtimezone_rrule_transitions.py index 0192e7f1b..5d631f1aa 100644 --- a/src/icalendar/tests/test_vtimezone_rrule_transitions.py +++ b/src/icalendar/tests/test_vtimezone_rrule_transitions.py @@ -1,16 +1,13 @@ -"""A VTIMEZONE RRULE with too many transitions is invalid. +"""A VTIMEZONE RRULE must repeat yearly. Timezones created from a VTIMEZONE expand its RRULE from DTSTART. -A frequent RRULE could exhaust memory or CPU. +An RRULE such as FREQ=SECONDLY could exhaust memory or CPU. """ -from datetime import datetime - import dateutil.rrule import pytest -import icalendar.config as cfg -from icalendar import Calendar, Timezone +from icalendar import vRecur # Far more than any test here needs, small enough to stop in seconds. MAX_EXPANDED_TRANSITIONS = 10_000 @@ -32,99 +29,45 @@ def bounded_expand(self): monkeypatch.setattr(dateutil.rrule.rrule, "_iter", bounded_expand) -VTIMEZONE = """BEGIN:VTIMEZONE -TZID:{tzid} -BEGIN:STANDARD -DTSTART:20370101T000000 -RRULE:{rrule} -TZOFFSETFROM:+0100 -TZOFFSETTO:+0000 -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:20370601T000000 -RRULE:FREQ=YEARLY -TZOFFSETFROM:+0000 -TZOFFSETTO:+0100 -END:DAYLIGHT -END:VTIMEZONE -""" +SECONDLY_ICAL = "pr_2047_vtimezone_secondly_rrule_ical.ics" +SECONDLY_JCAL = "pr_2047_vtimezone_secondly_rrule_jcal.jcal" +SECONDLY_XCAL = "pr_2047_vtimezone_secondly_rrule_xcal.xml" -CALENDAR = """BEGIN:VCALENDAR -{vtimezone}BEGIN:VEVENT -UID:transitions -DTSTART;TZID={tzid}:20370701T120000 -END:VEVENT -END:VCALENDAR -""" + +def test_secondly_rrule_from_1970_is_invalid(calendars): + """Reading the offset of an event in a SECONDLY timezone fails fast. + + Without the check, this exhausts memory. + """ + with pytest.raises(ValueError, match="FREQ=YEARLY"): + calendars[SECONDLY_ICAL].events[0].DTSTART.utcoffset() -def calendar(tzid: str, rrule: str) -> str: - vtimezone = VTIMEZONE.format(tzid=tzid, rrule=rrule) - return CALENDAR.format(vtimezone=vtimezone, tzid=tzid).replace("\n", "\r\n") +@pytest.mark.parametrize("source", [SECONDLY_ICAL, SECONDLY_JCAL, SECONDLY_XCAL]) +@pytest.mark.parametrize("method", ["to_tz", "get_transitions"]) +def test_secondly_rrule_is_invalid_in_all_formats(calendars, tzp, source, method): + """iCalendar, jCal and xCal reject the timezone.""" + with pytest.raises(ValueError, match="FREQ=YEARLY"): + timezone = calendars[source].timezones[0] + if method == "to_tz": + timezone.to_tz(tzp, lookup_tzid=False) + else: + timezone.get_transitions() @pytest.mark.parametrize( - "rrule", - [ - "FREQ=SECONDLY", - "FREQ=MINUTELY", - "FREQ=HOURLY", - "FREQ=DAILY", - "FREQ=YEARLY;BYMONTH=1,2,3,4,5,6,7,8,9,10,11,12;BYHOUR=0,1,2,3,4,5,6,7,8,9", - ], + "freq", ["SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY"] ) -def test_frequent_rrule_is_invalid(tzp, rrule): - """Parsing a VTIMEZONE with too many transitions fails fast.""" - tzid = f"transitions-{tzp.name}-{rrule}" - with pytest.raises(ValueError, match="transitions within a year"): - Calendar.from_ical(calendar(tzid, rrule)) - - -@pytest.mark.parametrize("rrule", ["FREQ=YEARLY", "FREQ=MONTHLY", "FREQ=WEEKLY"]) -def test_rrule_within_limit_is_used(tzp, rrule): - """Timezones with few transitions a year still work.""" - tzid = f"transitions-{tzp.name}-{rrule}" - event = Calendar.from_ical(calendar(tzid, rrule)).events[0] - assert event.DTSTART == datetime(2037, 7, 1, 12, tzinfo=event.DTSTART.tzinfo) - assert event.DTSTART.tzinfo is not None - - -def test_limit_can_be_disabled(tzp, monkeypatch): - """Trusted input can use more transitions.""" - monkeypatch.setattr(cfg, "MAX_TIMEZONE_TRANSITIONS_PER_YEAR", -1) - vtimezone = VTIMEZONE.format(tzid="transitions-disabled", rrule="FREQ=DAILY") - timezone = Timezone.from_ical(vtimezone.replace("\n", "\r\n")) - assert timezone.to_tz(tzp, lookup_tzid=False) is not None - - -PR_EXAMPLE = """BEGIN:VCALENDAR -BEGIN:VTIMEZONE -TZID:Custom -BEGIN:STANDARD -DTSTART:19700101T000000 -RRULE:FREQ=SECONDLY -TZOFFSETFROM:+0100 -TZOFFSETTO:+0000 -END:STANDARD -BEGIN:DAYLIGHT -DTSTART:19700601T000000 -RRULE:FREQ=YEARLY -TZOFFSETFROM:+0000 -TZOFFSETTO:+0100 -END:DAYLIGHT -END:VTIMEZONE -BEGIN:VEVENT -UID:a -DTSTART;TZID=Custom:20250101T120000 -END:VEVENT -END:VCALENDAR -""".replace("\n", "\r\n") - - -def test_secondly_rrule_from_1970_is_invalid(tzp): - """Reading the offset of an event in a SECONDLY timezone fails fast. - - Without the limit, this exhausts memory. - """ - with pytest.raises(ValueError, match="transitions within a year"): - Calendar.from_ical(PR_EXAMPLE).events[0].DTSTART.utcoffset() +def test_rrule_that_does_not_repeat_yearly_is_invalid(timezones, tzp, freq): + """Only a yearly RRULE is valid in a timezone.""" + timezone = timezones.pacific_fiji + timezone.standard[0]["RRULE"] = vRecur.from_ical(f"FREQ={freq}") + with pytest.raises(ValueError, match="FREQ=YEARLY"): + timezone.to_tz(tzp, lookup_tzid=False) + with pytest.raises(ValueError, match="FREQ=YEARLY"): + timezone.get_transitions() + + +def test_yearly_rrule_is_used(timezones, tzp): + """A yearly RRULE with BYxxx parts still works.""" + assert timezones.pacific_fiji.to_tz(tzp, lookup_tzid=False) is not None