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
138 changes: 129 additions & 9 deletions caldav/compatibility_hints.py

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions caldav/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,11 @@ def _parse_response(self, response: _Element) -> tuple[str, list[_Element], Any
## 404s; flag it in debug mode if a server pairs them with some
## other status so we notice and revisit this handling.
error.assert_("404" in status)
if status and " 404 " in status:
## The resource is gone, so whatever its propstats say is moot.
## Infomaniak adds an empty propstat with 418 to a removed member
## of a sync-collection reply, which would otherwise raise.
propstats = []
return (cast(str, href), propstats, status)

def _parse_scheduling_response_objects(self, parent) -> dict:
Expand Down
1 change: 1 addition & 0 deletions changes/+sogo-rewrites.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Compatibility features `delete.if-match` (a DELETE with a stale If-Match is refused with 412; SOGo deletes the object anyway, while it refuses a stale If-Match on PUT), `save-load.event.floating` (an event with a floating DTSTART/DTEND is stored floating; SOGo pins it to TZID=Etc/UTC) and `save-load.icalendar.attendee-params` (ATTENDEE parameters are stored as sent; SOGo adds RSVP=TRUE, which changes the meaning, as the default is FALSE). The SOGo profile has all three.
1 change: 1 addition & 0 deletions changes/+sync-404-propstats.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`Calendar.get_objects_by_sync_token()` no longer raises when a server answers a removed member with an extra propstat carrying an unexpected status: the propstats of a response whose own status is 404 are now ignored. Infomaniak adds an empty propstat with "418 I'm a teapot" to every removed member.
8 changes: 8 additions & 0 deletions changes/+sync-multiget-features.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Compatibility features for behaviours a sync client depends on, as found by calendaring-sync (https://github.com/PyCalendar/calendaring-sync/blob/c40782bc7afe7b350fe63bf27ef13374c280ea8b/docs/reference/server-compatibility.rst), each with a probe in caldav-server-tester:

- `delete.missing` and `delete.missing.if-match`: a DELETE of a missing object answers 404, and one with If-Match answers as the plain one does (Baikal, Davis, Nextcloud, CCS, OX, Robur, Purelymail and Infomaniak answer 412 with it).
- `save.etag.matches-stored`: a strong ETag in a PUT answer belongs to what was stored (Radicale, Xandikos, SOGo, CCS, Zimbra, Bedework 5 and Infomaniak rewrite the object and still answer with one).
- `sync-token.initial-sync`, `sync-token.delete.created-and-deleted` and `sync-token.recreated`: which members a sync-collection report lists.
- `multiget`, `multiget.missing-href` and `multiget.limit`, and `propfind.limit`: a calendar-multiget answers a missing href with a 404, and a multiget or a Depth: 1 PROPFIND answers every href (Stalwart stops at its maxResults, with a 507).

The Stalwart test container now runs with a maxResults of 50 (`STALWART_MAX_RESULTS`), so the limit can be probed with few objects.
37 changes: 37 additions & 0 deletions tests/docker-test-servers/stalwart/setup_stalwart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,43 @@ create_user "user1" "caldavtest1"
create_user "user2" "caldavtest2"
create_user "user3" "caldavtest3"

MAX_RESULTS="${STALWART_MAX_RESULTS:-50}"
echo ""
echo "Lowering WebDav.maxResults to ${MAX_RESULTS}..."
# Stalwart caps PROPFIND, sync-collection and calendar-multiget answers at
# WebDav.maxResults (2000 by default), with a 507 for the collection. A low
# cap lets caldav-server-tester see it with few objects
# (CALDAV_SERVER_TESTER_LIMIT_OBJECTS). The setting applies on a settings
# reload, which refuses the image's default SpamDnsblServer entries
# ("bit_and"); each entry it names is removed, in this container only.
RESULT=$(jmap_call "{
\"using\": [\"urn:ietf:params:jmap:core\"],
\"methodCalls\": [[\"x:WebDav/set\", {
\"accountId\": \"d333333\",
\"update\": {\"singleton\": {\"maxResults\": ${MAX_RESULTS}}}
}, \"0\"]]
}")
if ! echo "$RESULT" | grep -q '"updated"'; then
echo "Warning: setting maxResults returned: $RESULT"
fi
for i in $(seq 1 40); do
RESULT=$(jmap_call '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["x:Action/set",{"accountId":"d333333","create":{"r":{"@type":"ReloadSettings"}}},"0"]]}')
if echo "$RESULT" | grep -q '"created"'; then
echo "maxResults is ${MAX_RESULTS}"
break
fi
BAD=$(echo "$RESULT" | python3 -c "import json,sys; b=json.load(sys.stdin)['methodResponses'][0][1]['notCreated']['r']['objectId']; print(b['object'], b['id'])" 2>/dev/null || true)
if [ -z "$BAD" ] || [ "$i" -eq 40 ]; then
echo "Warning: settings reload returned: $RESULT"
break
fi
read -r BAD_OBJECT BAD_ID <<< "$BAD"
jmap_call "{
\"using\": [\"urn:ietf:params:jmap:core\"],
\"methodCalls\": [[\"x:${BAD_OBJECT}/set\", {\"accountId\": \"d333333\", \"destroy\": [\"${BAD_ID}\"]}, \"0\"]]
}" > /dev/null
done

echo ""
echo "Verifying CalDAV access..."
# v0.16+: CalDAV path encodes the @ in the email address as %40.
Expand Down
30 changes: 30 additions & 0 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,36 @@ def test_parse_sync_collection_generic_error(self):
assert result.deleted == ["/cal/gone.ics"]
assert result.changed == []

## Infomaniak (SabreDAV) answers a removed member with an empty propstat
## carrying 418 next to the 404 of the response itself
removed_with_teapot_xml = b"""<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/cal/gone.ics</D:href>
<D:propstat>
<D:prop/>
<D:status>HTTP/1.1 418 I'm a teapot</D:status>
</D:propstat>
<D:status>HTTP/1.1 404 Not Found</D:status>
</D:response>
<D:sync-token>tok</D:sync-token>
</D:multistatus>"""

def test_parse_propfind_ignores_propstats_of_a_404(self):
results = DAVResponse.from_bytes(self.removed_with_teapot_xml).parse_propfind()

assert [(r.href, r.properties, r.status) for r in results] == [("/cal/gone.ics", {}, 404)]

def test_expand_simple_props_ignores_propstats_of_a_404(self):
"""The legacy parser used by Calendar.get_objects_by_sync_token."""
from caldav.elements import dav

response = DAVResponse.from_bytes(self.removed_with_teapot_xml)
props = response.expand_simple_props([dav.GetEtag()])

assert props == {"/cal/gone.ics": {dav.GetEtag.tag: None}}
assert response.statuses == {"/cal/gone.ics": "HTTP/1.1 404 Not Found"}

def test_parse_nested_properties(self):
"""Issue https://github.com/python-caldav/caldav/issues/795: properties
nested deeper than one level gave the raw lxml element."""
Expand Down
Loading