Repair local_todo malformed ICS files with CRLF newlines on setup (#181581)

Co-authored-by: Home Assistant Developer <hello@home-assistant.io>
This commit is contained in:
Allen Porter
2026-09-11 18:26:59 +00:00
committed by Franck Nijhof
co-authored by Home Assistant Developer
parent 89006e3832
commit 5337ec6ee5
2 changed files with 254 additions and 9 deletions
+36 -8
View File
@@ -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)
+218 -1
View File
@@ -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