diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index da45068..ba1d39c 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -65,6 +65,24 @@ def _get_or_404_event(db, uid: str, owner: str) -> CalendarEvent: return ev +def _ics_escape(text: str) -> str: + """Escape a value for an iCalendar TEXT field (RFC 5545 §3.3.11). + + Backslash, semicolon and comma are structural in TEXT values and must be + escaped, and newlines become a literal ``\\n``. Backslash is escaped first + so the escapes we add aren't re-escaped. + """ + return ( + (text or "") + .replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n") + ) + + def _resolve_base_uid(uid: str) -> str: """Extract the base series UID from a compound occurrence UID. @@ -1038,7 +1056,7 @@ def setup_calendar_routes() -> APIRouter: for ev in events: lines.append("BEGIN:VEVENT") lines.append(f"UID:{ev.uid}") - lines.append(f"SUMMARY:{ev.summary or ''}") + lines.append(f"SUMMARY:{_ics_escape(ev.summary or '')}") if ev.all_day: lines.append(f"DTSTART;VALUE=DATE:{ev.dtstart.strftime('%Y%m%d')}") lines.append(f"DTEND;VALUE=DATE:{ev.dtend.strftime('%Y%m%d')}") @@ -1046,10 +1064,9 @@ def setup_calendar_routes() -> APIRouter: lines.append(f"DTSTART:{ev.dtstart.strftime('%Y%m%dT%H%M%S')}") lines.append(f"DTEND:{ev.dtend.strftime('%Y%m%dT%H%M%S')}") if ev.description: - desc = ev.description.replace(chr(10), '\\n') - lines.append(f"DESCRIPTION:{desc}") + lines.append(f"DESCRIPTION:{_ics_escape(ev.description)}") if ev.location: - lines.append(f"LOCATION:{ev.location}") + lines.append(f"LOCATION:{_ics_escape(ev.location)}") if ev.rrule: lines.append(f"RRULE:{ev.rrule}") lines.append("END:VEVENT") diff --git a/tests/test_ics_escape.py b/tests/test_ics_escape.py new file mode 100644 index 0000000..bc9321e --- /dev/null +++ b/tests/test_ics_escape.py @@ -0,0 +1,25 @@ +"""Tests for iCalendar TEXT escaping in calendar export (RFC 5545 §3.3.11).""" +from tests.test_null_owner_gates import _import_calendar_helpers + + +def _esc(): + return _import_calendar_helpers()._ics_escape + + +def test_escapes_comma_and_semicolon(): + # Regression: SUMMARY/LOCATION escaped nothing, so a comma/semicolon + # (structural in iCal TEXT values) corrupted the field in other clients. + assert _esc()("Lunch, dinner; meeting") == "Lunch\\, dinner\\; meeting" + + +def test_escapes_backslash_first(): + assert _esc()("path C:\\tmp") == "path C:\\\\tmp" + + +def test_newlines_become_literal_backslash_n(): + assert _esc()("line1\nline2\r\nline3") == "line1\\nline2\\nline3" + + +def test_empty_and_none_safe(): + assert _esc()("") == "" + assert _esc()(None) == ""