diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py index ded3b68d..ada27bf7 100644 --- a/caldav/async_davclient.py +++ b/caldav/async_davclient.py @@ -835,7 +835,7 @@ async def calendar_multiget( Returns: DAVResponse with results containing List[CalendarQueryResult]. """ - body = self._build_calendar_multiget_body(hrefs or []) + body = self._build_calendar_multiget_body(hrefs or [], features=self.features) final_headers = self._build_method_headers("REPORT", depth, headers) response = await self.request( diff --git a/caldav/base_client.py b/caldav/base_client.py index 4e98824e..fc2a389c 100644 --- a/caldav/base_client.py +++ b/caldav/base_client.py @@ -17,6 +17,7 @@ from lxml import etree +from caldav.compatibility_hints import at_spelling_for_report_href from caldav.elements import cdav, dav from caldav.elements.base import BaseElement from caldav.lib import error @@ -733,13 +734,18 @@ def _build_calendar_query_body( def _build_calendar_multiget_body( hrefs: list[str], include_data: bool = True, + features: Any = None, ) -> bytes: - """Build calendar-multiget REPORT request body.""" + """Build calendar-multiget REPORT request body. + + ``features`` spells the hrefs for the server, see + ``compatibility_hints.at_spelling_for_report_href``. + """ elements: list[BaseElement] = [] if include_data: elements.append(dav.Prop() + cdav.CalendarData()) for href in hrefs: - elements.append(dav.Href(href)) + elements.append(dav.Href(at_spelling_for_report_href(features, href))) multiget = cdav.CalendarMultiGet() + elements return etree.tostring(multiget.xmlelement(), encoding="utf-8", xml_declaration=True) diff --git a/caldav/collection.py b/caldav/collection.py index 4ec5d90e..70082241 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -45,6 +45,7 @@ ) from .compatibility_hints import ( at_literal_is_refused, + at_spelling_for_report_href, at_spelling_is_significant, ) from .davobject import DAVObject @@ -1523,7 +1524,12 @@ def _build_multiget_root(self, event_urls: Iterable[URL]) -> cdav.CalendarMultiG if self.url is None: raise ValueError("Unexpected value None for self.url") prop = dav.Prop() + [cdav.CalendarData(), dav.GetEtag(), cdav.ScheduleTag()] - return cdav.CalendarMultiGet() + prop + [dav.Href(value=u.path) for u in event_urls] + features = getattr(self.client, "features", None) + return ( + cdav.CalendarMultiGet() + + prop + + [dav.Href(value=at_spelling_for_report_href(features, u.path)) for u in event_urls] + ) def _extract_multiget_results( self, response: Any, raise_notfound: bool diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 2cf0a5cc..3fe8a546 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -207,6 +207,21 @@ class FeatureSet: ), "default": {"support": "full"}, }, + "url.encode-at.encoded.href": { + "description": ( + "Whether '%40' resolves in an href inside a REPORT body (a calendar-multiget), " + "as it does in the request path. RFC4918 section 8.3 makes such an href a URI " + "reference, so the server should decode it as it decodes the path. " + "'unsupported' is the Robur case: a GET of '.../foo%40example.com.ics' serves " + "the object, a multiget naming the same href answers 404, and a multiget naming " + "'.../foo@example.com.ics' finds it. There the client spells the '@' of a " + "multiget href literally and leaves every other escape alone - unless " + "'url.encode-at.identity' declares the two spellings different resources. It " + "does not change the spelling the client mints for a new object, which " + "'url.encode-at.encoded' decides." + ), + "default": {"support": "full"}, + }, "auth": { ## Grouping node, needed so that the dotted children below resolve; ## it carries no verdict of its own. Note that it does not derive @@ -590,6 +605,10 @@ class FeatureSet: "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.1", "https://datatracker.ietf.org/doc/html/rfc5545#section-3.2"], }, + "save-load.icalendar.attendee-once": { + "description": "Each ATTENDEE of an event comes back once. 'quirk' with behaviour 'stored twice' when every ATTENDEE line comes back twice, as an exact copy (Bedework 5, with or without ORGANIZER, RRULE or PARTSTAT); vcal.fix() drops an exact copy of an ATTENDEE line in the same component whenever iCalendar text is set on an object, whatever this hint says, so a client sees each attendee once. 'broken' when an attendee comes back twice with lines that differ, which the library keeps.", + "default": {"support": "full"}, + }, "save-load.icalendar.participant": { "description": "A PARTICIPANT component (RFC 9073 section 7.1) inside a VEVENT is stored and comes back. 'unsupported' when the event is stored without it. 'ungraceful' when the event is refused: CCS and Bedework 3 answer 403, robur 400, GMX and Ox 500 (and Zimbra, reportedly). A client keeping data of its own in PARTICIPANT components loses it on those servers.", "default": {"support": "full"}, @@ -2113,6 +2132,10 @@ def __getattr__(name: str) -> Any: ## deletes calendars, its sync-token and text search behave differently - so ## nothing is inherited from that profile. bedework_5_0_0 = { + ## Probed 2026-11-12 by caldav-server-tester, and by hand on a fresh + ## container: a PUT with one ATTENDEE gives two identical ATTENDEE lines + ## in GET and calendar-multiget alike. + "save-load.icalendar.attendee-once": {"support": "quirk", "behaviour": "stored twice"}, ## Measured with caldav-server-tester, 2026-10-27: the UID filter works although search.text does not. "search.text.uid": {"support": "full"}, ## Measured with caldav-server-tester, 2026-10-28. @@ -2623,6 +2646,13 @@ def __getattr__(name: str) -> Any: #] robur = { + ## Probed 2026-11-12 by caldav-server-tester: a calendar-multiget naming + ## .../x%40y.ics answers 404, one naming .../x@y.ics finds it; PUT and GET + ## of the %40 URL both work. + "url.encode-at.encoded.href": { + "support": "unsupported", + "behaviour": "a %40 in a calendar-multiget href is not decoded: 404 for an object the same URL serves", + }, ## Measured with caldav-server-tester, 2026-10-27. "search.text.uid": {"support": "unsupported", "behaviour": "objects not matching the UID come back"}, ## Probed 2026-10-26 by caldav-server-tester @@ -3503,6 +3533,22 @@ def at_spelling_to_mint(features: Any) -> str: return "@" if level in ("unsupported", "broken", "ungraceful") else "%40" +def at_spelling_for_report_href(features: Any, path: str) -> str: + """``path`` as an href in a REPORT body: with a literal ``@`` where + ``url.encode-at.encoded.href`` says the server will not decode ``%40`` there. + + A support level, not a boolean, for the reason :func:`at_spelling_to_mint` + gives; and never on a server declared conformant, where the literal + spelling names a different resource. + """ + if features is None: + return path + level = features.is_supported("url.encode-at.encoded.href", str) + if level in ("unsupported", "broken", "ungraceful") and at_spellings_are_aliased(features): + return path.replace("%40", "@") + return path + + def at_spellings_are_aliased(features: Any) -> bool: """True unless the server is declared to treat the two spellings apart. diff --git a/caldav/lib/vcal.py b/caldav/lib/vcal.py index 0683ba05..efa28bfd 100644 --- a/caldav/lib/vcal.py +++ b/caldav/lib/vcal.py @@ -109,6 +109,10 @@ def fix(event): proposed in https://github.com/py-vobject/vobject/pull/150). Drop it; the TZID is then resolved by name. + 9) Bedework 5 stores every ATTENDEE of an event twice. An exact + copy of an ATTENDEE line in the same component is dropped; a line + that differs, if only in a parameter, is kept. + All logic here is done with on the ical string, and not on icalendar objects. There are two reasons for it, originally optimization (not having to parse the icalendar data and create an @@ -170,6 +174,8 @@ def fix(event): ## DTEND/DUE is set. ## remove duplication of DTSTAMP fixed2 = "\n".join(filter(LineFilterDiscardingDuplicates(), fixed.strip().split("\n"))) + "\n" + ## 9 drop an exact copy of an ATTENDEE line + fixed2 = _drop_duplicate_attendees(fixed2) if fixed2 != event: ## This obscure code will ensure efficient rate-limiting of the error @@ -206,6 +212,36 @@ def is_power_of_two(n): return fixed2 +def _drop_duplicate_attendees(text): + """Bedework 5 stores every ATTENDEE of an event twice. An attendee is + one calendar user, so a line that is an exact copy of another in the + same component carries nothing; one that differs, if only in a + parameter, is kept. Folded lines are compared as a whole.""" + lines = text.split("\n") + out = [] + seen = [set()] + i = 0 + while i < len(lines): + j = i + 1 + while j < len(lines) and lines[j][:1] in (" ", "\t"): + j += 1 + logical = lines[i:j] + first = logical[0] + if first.startswith("BEGIN:"): + seen.append(set()) + elif first.startswith("END:") and len(seen) > 1: + seen.pop() + elif first.startswith(("ATTENDEE;", "ATTENDEE:")): + key = "".join(line[1:] if k else line for k, line in enumerate(logical)) + if key in seen[-1]: + i = j + continue + seen[-1].add(key) + out.extend(logical) + i = j + return "\n".join(out) + + class LineFilterDiscardingDuplicates: """Needs to be a class because it keeps track of whether a certain group of date line was already encountered within a vobject. diff --git a/changes/+bedework-attendee-twice.fixed.md b/changes/+bedework-attendee-twice.fixed.md new file mode 100644 index 00000000..268507d3 --- /dev/null +++ b/changes/+bedework-attendee-twice.fixed.md @@ -0,0 +1 @@ +Bedework 5 stores every ATTENDEE of an event twice. An exact copy of an ATTENDEE line in the same component is now dropped whenever iCalendar text is set on an object, on any server, so each attendee is seen once; a line that differs, if only in a parameter, is kept. The new hint `save-load.icalendar.attendee-once` records the behaviour: `quirk`, "stored twice", for Bedework 5. diff --git a/changes/+robur-encoded-href.fixed.md b/changes/+robur-encoded-href.fixed.md new file mode 100644 index 00000000..94b6b7f1 --- /dev/null +++ b/changes/+robur-encoded-href.fixed.md @@ -0,0 +1 @@ +A calendar-multiget now finds an object whose UID holds an `@` on Robur. Robur decodes `%40` in a request path but not in an href inside a REPORT body, so the multiget answered 404 for an object a GET of the same URL served. The new hint `url.encode-at.encoded.href` records that, and where it is `unsupported` the `@` of a multiget href is sent literally; other escapes are left alone. diff --git a/tests/test_url_encode_at.py b/tests/test_url_encode_at.py index 11c0c6d6..4acabcc0 100644 --- a/tests/test_url_encode_at.py +++ b/tests/test_url_encode_at.py @@ -516,3 +516,86 @@ def test_the_stalwart_profile_leaves_a_given_home_set_alone(self) -> None: def test_a_real_owncloud_observation_still_encodes(self) -> None: fs = features(identity="full", **{"literal.principal": "unsupported"}) assert _sanitize_calendar_home_set_url(HOME_SET, fs) == ENCODED_HOME_SET + + +class TestAnHrefInAReportBody: + """Robur resolves ``%40`` in a request path, but not in an href inside a + REPORT body: a calendar-multiget naming ``foo%40example.com.ics`` answers + 404 for an object a GET of that very URL serves. Declared as + ``url.encode-at.encoded.href``; there the client spells the ``@`` of a + multiget href literally, and leaves every other escape alone.""" + + PATH = "/cal/home/foo%40example.com.ics" + + def _hrefs(self, fs, path=PATH) -> list[str]: + from caldav.collection import Calendar + + client = Mock() + client.features = fs + client.url = URL("http://dav.example.com/", alias_at=at_spellings_are_aliased(fs)) + cal = Calendar(client, url="http://dav.example.com/cal/home/", parent=None) + root = cal._build_multiget_root([URL("http://dav.example.com" + path)]) + return [el.text for el in root.xmlelement().iter("{DAV:}href")] + + def test_the_child_exists_and_defaults_to_full(self) -> None: + assert "url.encode-at.encoded.href" in FeatureSet.FEATURES + assert FeatureSet().is_supported("url.encode-at.encoded.href") + + def test_declaring_it_leaves_the_minted_spelling_alone(self) -> None: + """A PUT or GET of '%40' still works there, so new objects keep their URL.""" + assert at_spelling_to_mint(features(**{"encoded.href": "unsupported"})) == "%40" + + @pytest.mark.parametrize("fs", [None, FeatureSet(), NO_LITERAL]) + def test_an_href_is_sent_as_it_always_was(self, fs) -> None: + assert self._hrefs(fs) == [self.PATH] + + def test_a_server_refusing_every_percent_40_gets_the_literal_too(self) -> None: + """A declared parent reaches the child: where '%40' resolves nowhere, + the href gets the literal the client mints there anyway.""" + assert self._hrefs(NO_ENCODED) == ["/cal/home/foo@example.com.ics"] + + def test_robur_gets_the_literal(self) -> None: + assert self._hrefs(features(**{"encoded.href": "unsupported"})) == [ + "/cal/home/foo@example.com.ics" + ] + + @pytest.mark.parametrize("level", ["unknown", "fragile"]) + def test_a_non_observation_changes_nothing(self, level) -> None: + assert self._hrefs(features(**{"encoded.href": level})) == [self.PATH] + + def test_other_escapes_are_kept(self) -> None: + path = "/cal/home/A7Cz%252Fb%3D%3D%40mychart.example.org.ics" + assert self._hrefs(features(**{"encoded.href": "unsupported"}), path) == [ + "/cal/home/A7Cz%252Fb%3D%3D@mychart.example.org.ics" + ] + + def test_a_conformant_server_keeps_its_spelling(self) -> None: + """There '%40' and '@' are two resources, so the client may not swap them.""" + fs = features(identity="full", **{"encoded.href": "unsupported"}) + assert self._hrefs(fs) == [self.PATH] + + def test_the_low_level_multiget_body_gets_it_too(self) -> None: + """``AsyncDAVClient.calendar_multiget`` builds its body on its own.""" + from caldav.base_client import BaseDAVClient + + build = BaseDAVClient._build_calendar_multiget_body + robur = features(**{"encoded.href": "unsupported"}) + assert b"foo@example.com.ics" in build([self.PATH], features=robur) + assert self.PATH.encode() in build([self.PATH]) + assert self.PATH.encode() in build([self.PATH], features=FeatureSet()) + + def test_an_answer_with_the_literal_is_matched_to_the_object(self) -> None: + """Robur answers with the href as it was sent, ``@`` and all; the + object asked for under its ``%40`` URL must still get the data.""" + from caldav.calendarobjectresource import Event + from caldav.collection import Calendar + + client = Mock() + client.features = features(**{"encoded.href": "unsupported"}) + client.url = URL( + "http://dav.example.com/", alias_at=at_spellings_are_aliased(client.features) + ) + cal = Calendar(client, url="http://dav.example.com/cal/home/", parent=None) + event = Event(client=client, url=cal.url.join("foo%40example.com.ics"), parent=cal) + cal._assign_multiget_data([event], [("/cal/home/foo@example.com.ics", EVENT, {})]) + assert event.data and "foo@example.com" in event.data diff --git a/tests/test_vcal.py b/tests/test_vcal.py index 3c8626f3..903bf9ee 100644 --- a/tests/test_vcal.py +++ b/tests/test_vcal.py @@ -710,6 +710,72 @@ def test_completed_date_fixup_preserves_next_property(self) -> None: "COMPLETED was not parsed as the fixed-up 20070501T120000Z" ) + def test_a_duplicated_attendee_is_dropped(self) -> None: + """Bedework 5 stores every ATTENDEE of an event twice: a PUT with + one gives two identical lines back. An attendee is one calendar + user, so the exact copy carries nothing; one that differs in a + parameter is kept.""" + ical = ( + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//x//EN\nBEGIN:VEVENT\nUID:a\n" + "DTSTAMP:20261001T000000Z\nDTSTART:20261102T090000Z\n" + "ATTENDEE;CN=Bob:mailto:bob@example.com\n" + "ATTENDEE;CN=Bob:mailto:bob@example.com\n" + 'ATTENDEE;CN="Åse";PARTSTAT=ACCEPTED:mailto:ase@example.com\n' + 'ATTENDEE;CN="Åse";PARTSTAT=ACCEPTED:mailto:ase@example.com\n' + "ATTENDEE;CN=Bob;PARTSTAT=DECLINED:mailto:bob@example.com\n" + "END:VEVENT\nBEGIN:VEVENT\nUID:a\nRECURRENCE-ID:20261109T090000Z\n" + "DTSTAMP:20261001T000000Z\nDTSTART:20261109T100000Z\n" + "ATTENDEE;CN=Bob:mailto:bob@example.com\n" + "END:VEVENT\nEND:VCALENDAR\n" + ) + fixed = vcal.fix(ical) + master, override = fixed.split("BEGIN:VEVENT")[1:] + assert master.count("ATTENDEE;CN=Bob:mailto:bob@example.com") == 1 + assert master.count("mailto:ase@example.com") == 1 + assert "PARTSTAT=DECLINED" in master, "a line that differs is not a duplicate" + assert override.count("mailto:bob@example.com") == 1, "the override keeps its own" + + def test_a_folded_duplicated_attendee_is_dropped(self) -> None: + line = "ATTENDEE;CN=Someone With A Long Name;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION:mailto:someone@example.com" + folded = line[:70] + "\n " + line[70:] + ical = ( + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//x//EN\nBEGIN:VEVENT\nUID:b\n" + f"DTSTAMP:20261001T000000Z\nDTSTART:20261102T090000Z\n{folded}\n{folded}\n" + "END:VEVENT\nEND:VCALENDAR\n" + ) + assert vcal.fix(ical).count("ATTENDEE") == 1 + + def test_an_alarm_keeps_its_own_attendee(self) -> None: + """An email VALARM names its recipient with ATTENDEE; each component + has its own attendees, and a copy after END:VALARM is still one.""" + ical = ( + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//x//EN\nBEGIN:VEVENT\nUID:c\n" + "DTSTAMP:20261001T000000Z\nDTSTART:20261102T090000Z\n" + "ATTENDEE:mailto:bob@example.com\n" + "BEGIN:VALARM\nACTION:EMAIL\nTRIGGER:-PT15M\nSUMMARY:x\nDESCRIPTION:x\n" + "ATTENDEE:mailto:bob@example.com\nEND:VALARM\n" + "ATTENDEE:mailto:bob@example.com\n" + "X-ATTENDEEX:mailto:bob@example.com\nATTENDEEX:a\nATTENDEEX:a\n" + "END:VEVENT\nEND:VCALENDAR\n" + ) + fixed = vcal.fix(ical) + alarm = fixed.split("BEGIN:VALARM")[1].split("END:VALARM")[0] + assert alarm.count("ATTENDEE:mailto:bob@example.com") == 1 + assert fixed.count("ATTENDEE:mailto:bob@example.com") == 2, ( + "one in the event, one in the alarm" + ) + assert fixed.count("ATTENDEEX:a") == 2, "another property is not an ATTENDEE" + + def test_copies_folded_at_different_points_are_one(self) -> None: + line = "ATTENDEE;CN=Someone With A Long Name;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION:mailto:someone@example.com" + ical = ( + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//x//EN\nBEGIN:VEVENT\nUID:d\n" + "DTSTAMP:20261001T000000Z\nDTSTART:20261102T090000Z\n" + f"{line[:60]}\n {line[60:]}\n{line[:70]}\n {line[70:]}\n" + "END:VEVENT\nEND:VCALENDAR\n" + ) + assert vcal.fix(ical).count("ATTENDEE") == 1 + def test_missing_dtstamp_fix(self) -> None: """ Test that missing DTSTAMP is added by the fix function.