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
46 changes: 46 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 @@ -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"},
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
36 changes: 36 additions & 0 deletions caldav/lib/vcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions changes/+bedework-attendee-twice.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
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
66 changes: 66 additions & 0 deletions tests/test_vcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading