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
1 change: 1 addition & 0 deletions news/2077.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed a bug where a content line folded in the middle of a multi-byte UTF-8 character parsed to U+FFFD replacement characters: raw bytes are now unfolded before decoding, so such values round-trip losslessly (RFC 5545 section 3.1 anticipates such folds). I used AI to help with this change. @rahul05ranjan
24 changes: 22 additions & 2 deletions src/icalendar/parser/content_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
# to the first line break of a run, making a failed attempt O(1) instead of a
# full rescan, while matching exactly the same strings.
UFOLD = re.compile(r"(?:(?<!\n)\r\n|(?<![\r\n])\n)(?:\r?\n)*[ \t]")
# The same pattern on raw bytes, to unfold an undecodable input before
# decoding it: a producer may fold a line in the middle of a multi-byte
# UTF-8 sequence, and decoding first would replace those bytes with
# U+FFFD before they can be rejoined (RFC 5545 section 3.1 anticipates
# that producers may split multi-octet characters).
UFOLD_BYTES = re.compile(rb"(?:(?<!\n)\r\n|(?<![\r\n])\n)(?:\r?\n)*[ \t]")
NEWLINE = re.compile(r"\r?\n")

OWS = " \t"
Expand Down Expand Up @@ -318,6 +324,12 @@ def value_separator_index(self) -> int:
@classmethod
def from_ical(cls, ical, strict=False):
"""Unfold the content lines in an iCalendar into long content lines."""
if isinstance(ical, bytes):
# unfold before decoding, so a fold inside a multi-byte UTF-8
# character can be rejoined instead of being replaced; folds
# cannot reappear during decoding, so the str unfold below is
# unnecessary for bytes input
return cls(to_unicode(UFOLD_BYTES.sub(b"", ical)), strict=strict)
ical = to_unicode(ical)
# a fold is carriage return followed by either a space or a tab
return cls(UFOLD.sub("", ical), strict=strict)
Comment on lines +327 to 335

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is potential for unfolding twice. This seems to be redundant. Have you checked, if this is correct the way it is now?

Expand All @@ -342,10 +354,18 @@ def to_ical(self):
@classmethod
def from_ical(cls, st):
"""Parses a string into content lines."""
st = to_unicode(st)
try:
if isinstance(st, bytes):
# unfold before decoding, so a fold inside a multi-byte UTF-8
# character can be rejoined instead of being replaced; folds
# cannot reappear during decoding, so the str unfold below is
# unnecessary for bytes input
st = to_unicode(UFOLD_BYTES.sub(b"", st))
unfolded = st
else:
st = to_unicode(st)
# a fold is carriage return followed by either a space or a tab
unfolded = UFOLD.sub("", st)
try:
lines = cls(Contentline(line) for line in NEWLINE.split(unfolded) if line)
lines.append("") # '\r\n' at the end of every content line
except Exception as e:
Expand Down
19 changes: 19 additions & 0 deletions src/icalendar/tests/test_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import pytest

from icalendar import Calendar


@pytest.mark.parametrize(
("field", "expected_value"),
Expand Down Expand Up @@ -96,3 +98,20 @@ def test_apple_xlocation(calendars):
"""
for event in calendars.x_location.walk("vevent"):
assert len(event.errors) == 0, "Got too many errors"


def test_fold_inside_multibyte_utf8_sequence_issue_2077():
"""A fold in the middle of a multi-byte UTF-8 sequence must not
garble the value into U+FFFD replacement characters: the raw bytes
are unfolded before decoding (issue #2077).
"""
summary = "A" * 70 + "\U0001f389" + "B" * 20
raw = ("SUMMARY:" + summary).encode("utf-8")
split = raw.index(b"\xf0\x9f") + 1 # inside the 4-byte sequence
folded = raw[:split] + b"\r\n " + raw[split:]
ics = (
b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n"
b"BEGIN:VEVENT\r\nUID:1\r\n" + folded + b"\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)
event = Calendar.from_ical(ics).walk("VEVENT")[0]
assert str(event["SUMMARY"]) == summary
Loading