⎇
..
src
tests
Cargo.toml744 B
README.md22.3 KB
READMERaw

pimdav

CalDAV and CardDAV logic without I/O. The caller loads data, calls this crate, and stores the result. The crate has no database types, no storage trait and no async code. The server in server/src/api/pim*.rs does the HTTP, authentication, storage and access checks.

The crate uses two libraries:

  • calcard parses and writes iCalendar and vCard. Its recurrence expansion is not used: it drops or duplicates instances on common client data. calcard is pinned to one version, because the corpus test reads its fixtures.
  • rrule iterates RRULEs in wall-clock time only. Time zones, UNTIL, RDATE, EXDATE and overrides are handled here.
Module Content
expand Recurrence sets
zone TZID resolution, VTIMEZONE rules, wall time to UTC
object Validation of a PUT body
xml WebDAV XML parsing and multistatus building
report REPORT request bodies
filter calendar-query and addressbook-query filters
render calendar-data and address-data in responses
freebusy Busy time, free-busy replies
itip Implicit scheduling as iTIP messages
principal Principal search, system address book cards
bundle Whole collections as one file, and one file split into objects

The sections below describe how the crate reads the RFCs where the text leaves room. "We" means this crate and the server that uses it.

Recurrence expansion

expand::expand returns the instances that overlap a time window. A zero-length instance overlaps if it starts inside the window.

The recurrence set

  • The set is DTSTART, plus RRULE and RDATE, minus EXRULE and EXDATE. Duplicates count once.
  • DTSTART is always an instance, even when it does not match the RRULE. The exception: every RRULE has an UNTIL before DTSTART. Then the series has no instances from its rules and no DTSTART.
  • A UTC UNTIL is converted to wall time in the zone of DTSTART before the comparison. The rule iterates in wall time. Without the conversion, a series east of UTC loses its last instance.
  • A DATE UNTIL on a DATE-TIME series includes that whole day. A floating UNTIL on a zoned series counts as wall time.
  • COUNT and UNTIL together are invalid. We ignore COUNT.
  • EXRULE (RFC 2445) is applied.
  • Several RRULEs are united.
  • Components are grouped by component type and UID. Each group is one master with its overrides. A component without UID stands alone.
  • A component with RECURRENCE-ID is an override, even if it carries an RRULE.
  • Several masters for one UID are invalid. The highest SEQUENCE wins, and the first one wins a tie.
  • A component without DTSTART is skipped.

Overrides

  • An override matches its instance by RECURRENCE-ID only.
  • SEQUENCE never decides whether an override applies. It only picks between two overrides of the same instance. The first one wins a tie.
  • An EXDATE also removes an override of that instance.
  • An override whose RECURRENCE-ID matches no instance still shows. Clients display it, so hiding it would lose data.
  • THISANDFUTURE moves every later instance by the same offset and gives it the override's length. The latest such override before an instance applies. A later plain override still matches by its original time.
  • An all-day series matches RECURRENCE-ID and EXDATE by date alone, whatever zone the value names.

Values and zones

  • A DATE value ignores its TZID. RFC 5545 forbids the combination.
  • A floating RECURRENCE-ID, RDATE or EXDATE on a zoned series is read in the series zone. Clients write them that way.
  • A DATE EXDATE on a DATE-TIME series removes every instance on that day.
  • A TZID on a UTC value is ignored. The Z wins.
  • The caller passes a zone for floating values and dates. The server uses the query's timezone, then the collection's calendar-timezone, then UTC.

Durations and DST

  • DTEND gives an exact length. Every instance keeps it, so a series across a DST change keeps 23 hours if the first instance had 23.
  • DURATION gives a nominal length. Whole days count in wall time, so a day can last 23 or 25 hours.
  • Without DTEND or DURATION, a date lasts one day and a date-time lasts zero seconds.
  • DTEND before DTSTART gives a length of zero.
  • A wall time in a DST gap takes the offset before the gap. So 02:30 on a spring-forward day becomes 03:30 (RFC 5545, 3.3.5).
  • A repeated wall time takes its first occurrence.
  • Two instances that land on the same instant after the gap rule merge into one. They would have the same RECURRENCE-ID.

Time zones

A TZID is resolved in this order:

  1. An exact IANA name. It wins over the VTIMEZONE rules. Clients send stale rules, and the user means the place.
  2. A guessed IANA name. Sources are vendor prefixes such as /mozilla.org/.../Europe/Berlin, calcard's Windows name table, X-LIC-LOCATION and X-MICROSOFT-CDO-TZID.
  3. The VTIMEZONE rules, evaluated from STANDARD and DAYLIGHT.
  4. A fixed offset from a label such as (UTC+02:00) Athens.
  5. The floating zone.

A guess (step 2) must agree with the VTIMEZONE rules. The check compares the offsets in mid-January and mid-July of 2020 to 2022. On disagreement the rules win. This caught calcard mapping (GMT+01.00) Sarajevo to Lisbon.

A repeated TZID in one object is invalid. The first definition wins.

Limits of the VTIMEZONE evaluation:

  • Rules repeat their last offset after the year 2200.
  • One observance yields at most 5,000 onsets.
  • The wall-time conversion assumes at most one offset change within a day.

Rules we do not expand

These rules yield no instances. DTSTART stays an instance. RFC 7529 allows treating an unsupported rule as absent.

  • RSCALE other than GREGORIAN.
  • SKIP other than OMIT.
  • A leap month such as BYMONTH=5L.
  • A BYMONTH value outside 1 to 12.
  • An UNTIL that does not parse.

The rrule crate workaround

rrule treats BYDAY=1MO,TU as an intersection of the plain and numbered weekdays. RFC 5545 means a union. When a rule mixes both forms, we number every occurrence of the plain weekdays. For a MONTHLY rule, or a YEARLY rule with BYMONTH, that is 1TU to 5TU. Otherwise it is 1TU to 53TU.

Cost limits

  • One rule generates at most 1,000,000 occurrences per expansion. Past that, Expansion::truncated is set and later instances are missing. Iteration always starts at DTSTART, so a FREQ=MINUTELY rule from years ago reaches the cap.
  • A truncated expansion counts as matching in a time-range filter. A false match costs the client one extra object. A false miss would hide an event.

Reports and filters

Validation of a PUT body

  • A calendar object has one UID, one component type and no METHOD. Otherwise it fails valid-calendar-object-resource.
  • A vCard without UID is accepted. Several clients omit it. The server uses the resource name instead.
  • A VEVENT, VTODO, VJOURNAL or VFREEBUSY without DTSTAMP gets one (object::with_dtstamp). The line is inserted after its BEGIN line, so every other byte stays. RFC 5545 requires DTSTAMP, and iTIP uses it to order messages. The PUT then returns no ETag.

Text matching

Collation Behavior
i;octet Exact bytes
i;ascii-casemap ASCII letters fold. Default for CalDAV.
i;unicode-casemap NFKD, then lowercase. Default for CardDAV.

Any other collation fails with supported-collation.

RFC 5051 folds with titlecase mappings. We lowercase. For matching, both give the same result except for a few digraph characters.

Text matching reads a property through calcard's writer. There is one data model, so component indices match the expansion.

Time ranges

  • The rules of RFC 4791, 9.9 apply to VEVENT, VTODO, VJOURNAL, VFREEBUSY and VALARM.
  • VTODO follows the full table, including todos without DTSTART.
  • A VALARM trigger fires once per instance of its parent. RELATED=END and REPEAT count.

Errors

  • A filter that does not parse fails with CALDAV:valid-filter or CARDDAV:valid-filter.
  • Another malformed REPORT body answers 400.

calendar-data and address-data

  • The stored bytes come back unchanged unless the request narrows, expands or converts them.
  • expand returns one component per instance in UTC with a RECURRENCE-ID and without RRULE, RDATE or EXDATE. Dates stay dates.
  • One object may expand into at most 10,000 instances. Past that the REPORT fails with CALDAV:max-instances.
  • address-data can convert between vCard 3.0 and 4.0. calcard then writes CHARSET=UTF-8 on non-ASCII 3.0 values.
  • CR is written as 
 in XML, so returned data keeps its CRLF.

max-instances

We do not advertise max-instances, and PUT never checks it. An endless RRULE is valid and common. The occurrence cap protects the CPU instead.

Sync

  • A token is urn:fbng:sync:{collection id}-{seq}. The id makes a token of a deleted collection invalid for a new one at the same URL.
  • A token of another collection, or one newer than the collection, fails with valid-sync-token.
  • Deleted members come back as 404 responses.
  • With a limit, the oldest changes come first. The response adds a 507 for the collection and hands out the token of its last change.
  • The system address book has no change log. Only its current token is valid. After any principal change, clients get valid-sync-token and sync from scratch.

addressbook-query

  • test defaults to anyof, on the filter and on each prop-filter.
  • nresults truncates the result and adds a 507 for the collection.

Principal search

  • principal-property-search and Apple's calendarserver-principal-search search the display name, the calendar user addresses and the calendar user type.
  • A search without terms lists every principal. python-caldav sends such searches.

Scheduling

itip implements implicit scheduling (RFC 6638) between principals of one server. The caller maps addresses onto its principals through closures.

What sends messages

  • An organizer PUT sends a REQUEST to each attendee. Each attendee only gets the instances they are part of.
  • An attendee left out of an override gets an EXDATE for that instance.
  • Removed attendees get a CANCEL. An organizer DELETE cancels for everyone.
  • A change of only DTSTAMP, LAST-MODIFIED, CREATED, SEQUENCE, SENT-BY or X- properties sends nothing.
  • SCHEDULE-AGENT=CLIENT or NONE sends nothing for that address. An unknown value counts as NONE.
  • SCHEDULE-FORCE-SEND=REQUEST sends a REQUEST even without a change.
  • An attendee's change of their own PARTSTAT sends a REPLY. This works per instance, through an override or an added EXDATE.
  • An attendee DELETE declines every instance, unless the request carries Schedule-Reply: F.

What each side may change

  • The server owns the PARTSTAT of every attendee in the organizer object. An organizer PUT cannot set them.
  • A reschedule resets all attendees to NEEDS-ACTION and raises SEQUENCE. Shortening a series or adding an EXDATE is no reschedule.
  • An attendee may not change times, rules, the ORGANIZER or the attendee list. That fails with allowed-attendee-scheduling-object-change. An attendee may add EXDATEs, which declines those instances.
  • Other attendee changes, such as SUMMARY or alarms, stay in the attendee's copy.

Delivery into copies

  • A new copy goes into the attendee's default calendar. That is their oldest calendar that takes VEVENT.
  • An update keeps the attendee's alarms, TRANSP, PERCENT-COMPLETE and COMPLETED.
  • A CANCEL sets STATUS:CANCELLED on the copy. Nothing is deleted.
  • An attendee who deleted their copy gets it back with the next organizer update that is not quiet.
  • A REPLY for one instance creates an override in the organizer object.
  • An update that only changes other attendees' answers is quiet. It keeps the copy's Schedule-Tag and leaves no inbox entry.

Status codes

The organizer object carries the status on each ATTENDEE. An attendee's copy carries the status of its last reply on the ORGANIZER.

SCHEDULE-STATUS Meaning
1.2 Delivered to a local principal
2.0 The attendee replied. The reply's REQUEST-STATUS wins if present.
3.7 An address in our domains that names no one, or a disabled account
5.2 An external address. There is no iMIP.
5.3 The recipient has no calendar for the component, for example a VTODO to a room

Schedule-Tag

  • The tag is the ETag of the version that last changed it.
  • A direct PUT by the attendee changes the tag, as RFC 6638, 3.2.10 requires.
  • A server-side merge of other attendees' answers keeps it.
  • caldav-server-tester flags schedule-tag.stable-partstat. It expects the attendee's own PARTSTAT PUT to keep the tag. We follow the RFC.

SENT-BY

  • The server sets SENT-BY only on writes that send a message: to the writer for a sharee, none for the owner. So every message names its real sender.
  • A write that sends nothing keeps SENT-BY as sent. The stored value can name the last sender until the next message. The PUT keeps its ETag.
  • It goes on the owner's ORGANIZER for invitations and on the owner's ATTENDEE for replies.

Free-busy

  • Free-busy covers the principal's own calendars that take VEVENT and are opaque (schedule-calendar-transp). It never covers the inbox or lent calendars.
  • Transparent and cancelled events are free. Stored VFREEBUSY components count with their FBTYPE.
  • For scheduling (outbox POST and room conflicts), the principal's own answer counts too. A declined instance is free. NEEDS-ACTION and TENTATIVE count as BUSY-TENTATIVE. RFC 6638 does not define this rule.
  • The free-busy-query REPORT follows RFC 4791 and ignores PARTSTAT.

Rooms and resources

  • The server answers an invitation for a room before messages go out.
  • An instance that overlaps an existing booking is declined. A declined instance of a series gets its own override. Everything else is accepted.
  • Tentative bookings block. Cancelled bookings, declined bookings and copies of the same UID do not.
  • Checks start now. Past instances are accepted unchecked.
  • A series without end is checked for the next two years. A series with COUNT or UNTIL, or a single event, is checked to its end, at most ten years ahead. Later instances are accepted unchecked. A series without end has infinite instances, so it needs a limit. Exchange's booking window works the same way.

Feeds, export and import

bundle works on the text, not on parsed data. Every stored line reaches the output unchanged. Only line endings become CRLF.

One file from a collection

  • A calendar becomes one VCALENDAR. Each VTIMEZONE appears once per TZID; the first definition wins.
  • The calendar carries NAME (RFC 7986) and X-WR-CALNAME with the display name. Most clients read only X-WR-CALNAME.
  • It also carries REFRESH-INTERVAL;VALUE=DURATION:PT1H (RFC 7986) and X-PUBLISHED-TTL:PT1H (Outlook). Clients still pick their own interval. An export carries them too; importers ignore them.
  • Detail picks what a file shows. An export shows everything. A public feed shows everything, except that an object with a CLASS:PRIVATE or CLASS:CONFIDENTIAL component shows as busy time only, as Google and Nextcloud do. The whole object is reduced, so its overrides keep matching their master. A busy-only feed reduces every object.
  • Busy time keeps VEVENTs only; tasks and journals go. An event keeps UID, DTSTAMP, DTSTART, DTEND, DURATION, RRULE, RDATE, EXDATE, EXRULE, RECURRENCE-ID, SEQUENCE, TRANSP and STATUS, and gets SUMMARY:Busy. DURATION and DTSTAMP stay: without them an event has no end or is invalid.
  • Transparent and cancelled events block no time, so busy time leaves them out. A left-out override becomes an EXDATE of its master, without a RANGE parameter, so its instance stays free.
  • Busy time replaces each UID with a SHA-256 hash of it. UIDs from Outlook and others hold host names or mail addresses. Master and overrides share one UID, so they share the hash.
  • An address book becomes all its vCards, one after the other.

Objects from one file

  • A calendar file becomes one object per UID. Overrides stay with their master, also across several VCALENDARs in one file.
  • Each object gets the VTIMEZONEs its TZID parameters name, and no others.
  • The object keeps VERSION, PRODID and CALSCALE of the file. METHOD and calendar properties such as X-WR-CALNAME and X-WR-TIMEZONE are dropped. Floating times then follow the collection's calendar-timezone.
  • A component without UID gets one from the caller. The server derives it from the component's text, so a second import of the same file updates instead of duplicating.
  • A component that appears twice with the same text is kept once.
  • A vCard without UID gets it before its END line. vCard 4.0 wants VERSION right after BEGIN.
  • The objects are not validated here. The server runs the same checks as for a PUT and skips what fails.

Where the RFCs are unclear or implementations differ

Case What we do Why
DTSTART does not match the RRULE DTSTART is an instance RFC 5545 calls the set undefined; libical, Google and Apple include it
TZID on a DATE value Ignore the TZID RFC 5545 forbids applying it
TZID on a UTC value Use the Z RFC 5545 forbids the TZID there
DTEND before DTSTART Length zero Other readings invent a start time
Orphan override Show it Clients show it; hiding loses the user's edit
EXDATE and an override on one instance Remove both The RFC excludes the instance; a stale override stays after a delete
COUNT and UNTIL together Use UNTIL Invalid input; UNTIL is the safer bound
Several masters per UID Highest SEQUENCE, first on a tie Invalid input; matches recurring-ical-events
Wall times in a DST gap that collide Merge them They share one RECURRENCE-ID
Floating EXDATE or RECURRENCE-ID on a zoned series Read in the series zone Thunderbird writes them that way
Missing Depth on PROPFIND Depth 0 RFC 4918 says infinity; clients that omit it mean 0
Depth on REPORT Ignored Queries always cover the collection's members
Organizer sets another attendee's PARTSTAT Overridden, not rejected The RFC says SHOULD reject; overriding keeps clients working
Organizer resets an attendee to NEEDS-ACTION Not possible The RFC's MAY; SCHEDULE-FORCE-SEND invites again
CANCEL to an attendee STATUS:CANCELLED on the copy Matches sabre/dav; the attendee sees what happened
Attendee PARTSTAT PUT and Schedule-Tag The tag changes RFC 6638, 3.2.10; caldav-server-tester expects the opposite
schedule-send* privileges on a shared calendar Listed there RFC 6638 puts them on the outbox, which a sharee cannot see
PARTSTAT in scheduling free-busy Own answer counts Not defined by RFC 6638
/.well-known redirect 307 A 301 drops the REPORT body; RFC 6764 allows 307
TZID property with escaped commas Unescaped as TEXT The property is TEXT; the TZID parameter holds the plain value, so Outlook's Athens\, Bucharest must match "Athens, Bucharest"
Import with X-WR-TIMEZONE Dropped It is not standard; floating times follow the collection instead

Not handled

  • THISANDFUTURE in scheduling. It is treated as a plain override. Clients split a series instead: they end it with UNTIL and create a new UID. Expansion does handle THISANDFUTURE.
  • Delegation (DELEGATED-TO). The attendee's change is rejected, because attendees may not change the attendee list.
  • Task progress replies. An assignee's PERCENT-COMPLETE and COMPLETED stay in their copy.
  • Non-Gregorian calendars (RSCALE). See "Rules we do not expand".
  • Full RFC 5051 folding. We lowercase after NFKD instead of using titlecase mappings.
  • iMIP. No email is sent or read. External attendees get 5.2.
  • Reports on a single object or a home. Only collections take calendar and address book reports. Others fail with supported-report.
  • Sharing through the protocol. Shares are created only through the server's JSON API. RFC 3744's ACL method is not implemented. Clients only read current-user-privilege-set, which we provide. Apple's calendarserver-sharing is not implemented either: no share or invite-reply POSTs, no notifications collection, no "shared by" properties. The IETF drafts based on it expired. Only Apple Calendar offers a sharing UI over the protocol. DAVx5, Thunderbird and Evolution have none, and most servers share through their own web UI too. So a lent calendar appears without an accept step. Only its display name "{name} ({owner})" shows the owner.

Testing

cargo test -p pimdav
File Covers
tests/expand.rs Recurrence cases the corpus cannot check
tests/corpus.rs Differential test of expand over 998 real files
tests/protocol.rs XML parsing and building, PUT validation
tests/report.rs Filters, rendering, free-busy
tests/itip.rs Scheduling messages and copies
tests/principal.rs Principal search, system address book cards

The server's end-to-end tests are server/tests/api_pim*.rs.

The corpus test

The test expands every .ics fixture of calcard and compares the start times with the Python library recurring-ical-events.

  • The fixtures come from calcard's crate sources in the cargo registry, under $CARGO_HOME or ~/.cargo. The pinned calcard version in Cargo.toml and the CALCARD constant in the test must match.
  • Both sides expand VEVENTs with starts in 1970 to 2040. Floating times and dates count as UTC. X-WR-TIMEZONE is ignored.
  • tests/corpus/oracle.tsv holds the expected count and a hash per file. oracle.py writes it.
  • tests/corpus/deviations.tsv overrides the oracle for a file. It covers files the oracle cannot parse and files where we differ on purpose.

To regenerate the oracle, for example after a calcard update:

cd pimdav/tests/corpus
F=$(ls -d ~/.cargo/registry/src/*/calcard-<version>/resources/ical)
uv run oracle.py "$F" > oracle.tsv
uv run oracle.py "$F" --full > /tmp/oracle-full.tsv

To review a difference, write our full output and compare it with the oracle's:

PIMDAV_CORPUS_FULL=/tmp/ours-full.tsv cargo test -p pimdav --test corpus

On failure, the test prints one line per differing file in the format of deviations.tsv. For each file, fix the code or add the line with a reason. Every line in deviations.tsv needs a reason. A line without one hides a bug.