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..5fe152e8 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 @@ -2623,6 +2638,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 +3525,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/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