Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion caldav/async_davclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions caldav/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion caldav/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions changes/+robur-encoded-href.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
83 changes: 83 additions & 0 deletions tests/test_url_encode_at.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading