From 5337ec6ee5a5ec222d98b299dca4c03641596cfe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 8 Sep 2026 03:09:40 -0700 Subject: [PATCH] Repair local_todo malformed ICS files with CRLF newlines on setup (#181581) Co-authored-by: Home Assistant Developer --- homeassistant/components/local_todo/todo.py | 44 +++- tests/components/local_todo/test_todo.py | 219 +++++++++++++++++++- 2 files changed, 254 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 0ba4439840ac..1d8b1048b5e2 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -7,6 +7,7 @@ from typing import override from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError from ical.store import TodoStore from ical.todo import Todo, TodoStatus @@ -63,6 +64,16 @@ def _migrate_calendar(calendar: Calendar) -> bool: return migrated +def _repair_legacy_crlf_newlines(content: str) -> str: + r"""Repair ICS content corrupted by CRLF newlines from ical <= 12.1.3. + + In ical <= 12.1.3, TextEncoder escaped \n to \\n but left \r unescaped. + When read with Python's universal newlines mode (newline=None), any lone \r + before \\n was converted into \n\\n, breaking property line parsing. + """ + return content.replace("\r\\n", "\\n").replace("\n\\n", "\\n").replace("\r", "") + + async def async_setup_entry( hass: HomeAssistant, config_entry: LocalTodoConfigEntry, @@ -70,20 +81,37 @@ async def async_setup_entry( ) -> None: """Set up the local_todo todo platform.""" + name = config_entry.data[CONF_TODO_LIST_NAME] store = config_entry.runtime_data ics = await store.async_load() - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): - # calendar_from_ics will dynamically load packages - # the first time it is called, so we need to do it - # in a separate thread to avoid blocking the event loop - calendar: Calendar = await hass.async_add_import_executor_job( - IcsCalendarStream.calendar_from_ics, ics + migrated = False + try: + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # calendar_from_ics will dynamically load packages + # the first time it is called, so we need to do it + # in a separate thread to avoid blocking the event loop + calendar: Calendar = await hass.async_add_import_executor_job( + IcsCalendarStream.calendar_from_ics, ics + ) + except CalendarParseError: + # Attempt to repair malformed newlines from ical <= 12.1.3 CRLF bug + repaired_ics = _repair_legacy_crlf_newlines(ics) + if repaired_ics == ics: + raise + calendar = await hass.async_add_import_executor_job( + IcsCalendarStream.calendar_from_ics, repaired_ics ) - migrated = _migrate_calendar(calendar) + _LOGGER.warning( + "Repaired malformed iCalendar file for to-do list %s", + name, + ) + migrated = True + + if _migrate_calendar(calendar): + migrated = True calendar.prodid = PRODID - name = config_entry.data[CONF_TODO_LIST_NAME] entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id) async_add_entities([entity], True) diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index f01597e89e19..992e01652952 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -23,8 +23,9 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .conftest import TEST_ENTITY +from .conftest import TEST_ENTITY, TODO_NAME +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator type WsGetItemsType = Callable[[], Coroutine[Any, Any, list[dict[str, str]]]] @@ -877,3 +878,219 @@ async def test_reset_item_via_update( state = hass.states.get(TEST_ENTITY) assert state assert state.state == "1" + + +@pytest.mark.parametrize( + ("ics_content", "expected_description"), + [ + pytest.param( + ( + "BEGIN:VCALENDAR\n" + "PRODID:-//homeassistant.io//local_todo 2.0//EN\n" + "VERSION:2.0\n" + "BEGIN:VTODO\n" + "DTSTAMP:20260205T183141Z\n" + "UID:077cb7f2-6c89-11ee-b2a9-0242ac110002\n" + "CREATED:20260205T183141Z\n" + "SEQUENCE:0\n" + "STATUS:NEEDS-ACTION\n" + "SUMMARY:Notizen\n" + "DESCRIPTION:Einkaufsliste\n\\nMilch\n\\nBrot\n" + "END:VTODO\n" + "END:VCALENDAR\n" + ), + "Einkaufsliste\nMilch\nBrot", + id="universal_newlines", + ), + pytest.param( + ( + "BEGIN:VCALENDAR\r\n" + "PRODID:-//homeassistant.io//local_todo 2.0//EN\r\n" + "VERSION:2.0\r\n" + "BEGIN:VTODO\r\n" + "DTSTAMP:20260205T183141Z\r\n" + "UID:077cb7f2-6c89-11ee-b2a9-0242ac110002\r\n" + "CREATED:20260205T183141Z\r\n" + "SEQUENCE:0\r\n" + "STATUS:NEEDS-ACTION\r\n" + "SUMMARY:Notizen\r\n" + "DESCRIPTION:Einkaufsliste\r\\nMilch\r\\nBrot\r\n" + "END:VTODO\r\n" + "END:VCALENDAR\r\n" + ), + "Einkaufsliste\nMilch\nBrot", + id="raw_crlf", + ), + pytest.param( + ( + "BEGIN:VCALENDAR\n" + "PRODID:-//homeassistant.io//local_todo 2.0//EN\n" + "VERSION:2.0\n" + "BEGIN:VTODO\n" + "DTSTAMP:20260205T183141Z\n" + "UID:077cb7f2-6c89-11ee-b2a9-0242ac110002\n" + "CREATED:20260205T183141Z\n" + "SEQUENCE:0\n" + "STATUS:NEEDS-ACTION\n" + "SUMMARY:Notizen\n" + "DESCRIPTION:Line 1\n\\n\n\\nLine 2\n" + "END:VTODO\n" + "END:VCALENDAR\n" + ), + "Line 1\n\nLine 2", + id="consecutive_newlines", + ), + ], +) +async def test_repair_legacy_crlf_on_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + ws_get_items: WsGetItemsType, + caplog: pytest.LogCaptureFixture, + expected_description: str, +) -> None: + """Test repairing malformed ICS content from ical <= 12.1.3 CRLF bug on setup.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + f"Repaired malformed iCalendar file for to-do list {TODO_NAME}" in caplog.text + ) + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.state == "1" + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "Notizen" + assert items[0]["description"] == expected_description + + # Verify that the file was re-saved cleanly to storage without any invalid lines + store = config_entry.runtime_data + re_saved = store._mock_path.read_text.return_value + assert "\r" not in re_saved + assert "\n\\n" not in re_saved + + +@pytest.mark.parametrize( + "ics_content", + [ + ( + "BEGIN:VCALENDAR\n" + "PRODID:-//homeassistant.io//local_todo 1.0//EN\n" + "VERSION:2.0\n" + "BEGIN:VTODO\n" + "DTSTAMP:20231024T014011\n" + "UID:077cb7f2-6c89-11ee-b2a9-0242ac110002\n" + "CREATED:20231017T010348\n" + "SEQUENCE:1\n" + "STATUS:NEEDS-ACTION\n" + "SUMMARY:Task\n" + "DESCRIPTION:Line 1\n\\nLine 2\n" + "DUE:20231023\n" + "END:VTODO\n" + "END:VCALENDAR\n" + ) + ], +) +async def test_repair_and_migrate_legacy_due_date( + hass: HomeAssistant, + config_entry: MockConfigEntry, + ws_get_items: WsGetItemsType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that repaired legacy 1.0 calendars also undergo due date migration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + f"Repaired malformed iCalendar file for to-do list {TODO_NAME}" in caplog.text + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["description"] == "Line 1\nLine 2" + assert items[0]["due"] == "2023-10-23" + + store = config_entry.runtime_data + re_saved = store._mock_path.read_text.return_value + assert "PRODID:-//homeassistant.io//local_todo 2.0//EN" in re_saved + assert "DUE;VALUE=DATE:20231024" in re_saved + + +async def test_description_newlines_preserved( + hass: HomeAssistant, + setup_integration: None, + config_entry: MockConfigEntry, + ws_get_items: WsGetItemsType, +) -> None: + """Test that newlines in descriptions are preserved through service calls and reload.""" + description = "Line 1\nLine 2\n\nLine 3" + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + { + ATTR_ITEM: "Item 1", + ATTR_DESCRIPTION: description, + }, + target={ATTR_ENTITY_ID: TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["description"] == description + + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.UPDATE_ITEM, + { + ATTR_ITEM: items[0]["uid"], + ATTR_DESCRIPTION: f"Updated\n{description}", + }, + target={ATTR_ENTITY_ID: TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["description"] == f"Updated\n{description}" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["description"] == f"Updated\n{description}" + + +async def test_crlf_description_preserved_on_reload( + hass: HomeAssistant, + setup_integration: None, + config_entry: MockConfigEntry, + ws_get_items: WsGetItemsType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that items added with CRLF descriptions are saved cleanly and load on reload.""" + await hass.services.async_call( + TODO_DOMAIN, + TodoServices.ADD_ITEM, + { + ATTR_ITEM: "Item CRLF", + ATTR_DESCRIPTION: "Line 1\r\nLine 2\r\n\r\nLine 3", + }, + target={ATTR_ENTITY_ID: TEST_ENTITY}, + blocking=True, + ) + + # After reload from storage, newlines are preserved as LF and no repair warning is logged + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["description"] == "Line 1\nLine 2\n\nLine 3" + assert "Repaired malformed iCalendar file" not in caplog.text