Add the CalDAV client and a CLI to drive it

Discovery is the three PROPFINDs RFC 4791 describes rather than a walk
through likely URLs. Queries use time-range, which v1 never did -- it
fetched whole calendars and filtered in the browser on every view change.
Every write states a precondition, so a stale ETag produces a Conflict a
caller can act on instead of silently destroying somebody's edit.

XML goes through quick-xml with namespace resolution. v1 matched prefixes
with six regexes tried in sequence and recompiled inside the loop; there
is a fixture here that is the same document under different prefixes, and
it parses identically.

Protocol parsing is split from transport so it can be tested against
responses recorded from a real Baikal -- including the second propstat
carrying 404s, which is what makes "this calendar has no colour" different
from "this calendar has an empty colour".

Live tests run against a real server, never a mock. tests/baikal/run.sh
starts a container, walks Baikal's install wizard, and runs them; each
test builds and destroys its own collection, so pointing it at a real
server touches nothing that was already there. They cover discovery,
round-trip, stale-ETag conflict, duplicate create, delete, time-range
filtering, a series returned whole with its override, and writing every
synthetic golden fixture to the server and reading it back.

libdav was evaluated first, as planned. Not adopted: its HttpClient trait
is defined over hyper::body::Incoming, so using it means replacing reqwest
everywhere, plus a DNS resolver for service discovery we do not do and a
second XML parser. Its precondition design is where Precondition's shape
comes from. Reasons are recorded in the crate docs.
This commit is contained in:
2026-08-26 15:32:08 -04:00
parent 689b78154b
commit f8e4a497fa
20 changed files with 2893 additions and 3 deletions
@@ -0,0 +1,200 @@
<?xml version="1.0" ?>
<multistatus xmlns="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<response>
<href>/dav.php/calendars/alex/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/household-chores/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Household Chores</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DC2626</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/4</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/partner-chores/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Partner Chores</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#7C3AED</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/6</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/trips/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Trips</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<cs:getctag>http://sabre.io/ns/sync/23</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/workouts/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Workouts</displayname>
<CAL:calendar-description>Calendar for logging workouts</CAL:calendar-description>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#3B82F6</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/64</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/birthdays/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
<cs:shared-owner/>
</resourcetype>
<displayname>Birthdays</displayname>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DD403A</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/36</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/personal/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Personal</displayname>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
<CAL:comp name="VTODO"/>
<CAL:comp name="VJOURNAL"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#0CCE6B</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/930</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/inbox/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:schedule-inbox/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/outbox/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:schedule-outbox/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
</multistatus>