| .. | ||
| src | ||
| tests | ||
| Cargo.toml | 744 B | |
| README.md | 31.8 KB | |
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 |
view |
Events and contacts summarized for a user interface |
contact |
Contact photos, birthday events |
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
Zwins. - The caller passes a zone for floating values and dates. The server uses
the query's
timezone, then the collection'scalendar-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:
- An exact IANA name. It wins over the VTIMEZONE rules. Clients send stale rules, and the user means the place.
- A guessed IANA name. Sources are vendor prefixes such as
/mozilla.org/.../Europe/Berlin, calcard's Windows name table,X-LIC-LOCATIONandX-MICROSOFT-CDO-TZID. - The VTIMEZONE rules, evaluated from STANDARD and DAYLIGHT.
- A fixed offset from a label such as
(UTC+02:00) Athens. - 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.
RSCALEother thanGREGORIAN.SKIPother thanOMIT.- 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::truncatedis set and later instances are missing. Iteration always starts at DTSTART, so aFREQ=MINUTELYrule 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. - A VEVENT without DTSTART, or a VTODO with DURATION but no DTSTART, fails
valid-calendar-data, as RFC 5545 requires. Import skips such objects. Not checked: DTEND together with DURATION, DUE together with DURATION, and a VTIMEZONE for every TZID. Clients get these wrong often enough that rejecting them would lose data.
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-filterorCARDDAV: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.
expandreturns 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. - One answer may expand into at most 20,000 instances across its objects.
Past that it stops before the next object and ends with a 507 for the
collection, with
DAV:number-of-matches-within-limits, as for a client limit. RFC 4791 (7.8) lets a server cut results short. This bounds the memory of one request: a year of a 50,000-event calendar was 247 MB. address-datawithout a version means 3.0 (RFC 6352, 10.4). A 4.0 card is then converted, see "vCard versions" below. calcard writesCHARSET=UTF-8on 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:dovenest: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 generated collections ignore a limit and always answer in full. They have no change log that a cut answer could resume from.
- The system address book has no change log. Its token is a hash of the
principal list, so it is known without building the cards. Only the
current token is valid. After any principal change, clients get
valid-sync-tokenand sync from scratch.
addressbook-query
testdefaults toanyof, on the filter and on each prop-filter.nresultstruncates the result and adds a 507 for the collection.
Principal search
principal-property-searchand Apple'scalendarserver-principal-searchsearch 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=CLIENTorNONEsends nothing for that address. An unknown value counts asNONE.SCHEDULE-FORCE-SEND=REQUESTsends 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.
itip::respondanswers for an attendee without an app: it sets their PARTSTAT in every component, or in one instance, which then gets an override of its own. Stored through the normal PUT path, the change sends the REPLY.
Delivery into copies
- A new copy goes into the attendee's default calendar: the one set with
schedule-default-calendar-URLon the inbox, else the oldest calendar that takes VEVENT. Only own calendars that take VEVENT can be set. - A message changes only the recipient's attendee copy of a meeting the sender organizes. A UID proves nothing, since anyone can pick any UID: if the recipient holds the UID in any other object, the message is not delivered and that object stays untouched. A reply likewise reaches only the organizer's own object.
- 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 |
| 3.8 | The recipient holds this UID in an object that is not its copy of the sender's meeting |
| 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-queryREPORT 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.
- The answer is given once, on receipt, and stored. Nothing checks it again later. A collision beyond the checked window stays accepted until the organizer sends an update, which the room answers from that day on.
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) andX-PUBLISHED-TTL:PT1H(Outlook). Clients still pick their own interval. An export carries them too; importers ignore them. Detailpicks 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.
calendar_metareads the name and color a file gives itself, for an import as a new calendar: NAME before X-WR-CALNAME, COLOR before X-APPLE-CALENDAR-COLOR. RFC 7986's COLOR is a CSS color name, which the server ignores; only hex colors count.
Contacts
contact derives two things from a vCard. The server stores neither.
Photos
- Only an inline PHOTO counts: vCard 3
ENCODING=b, or a vCard 4data:URI. A photo given as a URL is never fetched: a contact could then make the server request any address. - The server never serves the stored bytes. They come from a client and could be HTML or SVG with script. They are re-encoded as WebP. The thumbnail cache keeps the result, keyed by the object's ETag, so an edited contact gets a new image. Without a cache the server re-encodes on each request. The ETag is the object's, so a client that already has the photo gets a 304 without a decode.
Birthdays and anniversaries
- BDAY, and ANNIVERSARY (vCard 4) or X-ANNIVERSARY (vCard 3, Evolution
and KDE). Apple writes an anniversary as
itemN.X-ABDATE, labelled by anitemN.X-ABLabelthat contains "Anniversary"; that counts too. Only the first date of each kind counts. - The date forms are
19800315,1980-03-15,--0315and--03-15, with or without a time. calcard reads--03-15as a month alone, so the dates are read from the text. Apple'sX-APPLE-OMIT-YEARmarks a placeholder year. A TEXT value and an impossible date are skipped. - Each date becomes a yearly, all-day, transparent event with a fixed DTSTAMP, so its ETag changes only with the contact.
- The UID is a hash of the contact's collection, its resource name and the kind of date. It stays while the contact stays.
- A February 29 recurs with
BYMONTH=2;BYMONTHDAY=-1: the last day of February in other years. Without a year, the series starts in 1972, or in 1970 for other dates. - The summary is
🎂 Nameor💍 Name, with the birth year in brackets when known. It carries no age: one recurring event cannot hold a value that changes each year. The signs avoid words, so the server needs no language.
The server builds the birthday calendar of a principal from its own address books only, when a client reads its members. Like the system address book, it has no change log: its sync token is a hash of the address books' ids and change counters, and only the current token is valid. So any contact change, not only a birthday, makes clients resync it. A REPORT that reads many members builds them once. It is read-only, not shareable, has no feed links, and is left out of free-busy and of the choice of the calendar that receives invitations.
Forgetting a deleted principal
itip::forget rewrites the objects of other principals before an account,
room or resource is deleted. Without it, a later principal of the same name
would get the same mailto: address and take the old one's place in every
meeting.
- Every ORGANIZER and ATTENDEE that names it gets the tombstone address
mailto:<name>-<id>@deleted.dovenest.invalid. Its parameters stay, CN among them. - Such an ATTENDEE gets SCHEDULE-STATUS 3.7. A later message to it is not delivered, with status 3.7 as for any unknown address in our domains.
- A component the deleted principal organized gets STATUS:CANCELLED: an existing STATUS line changes, otherwise one follows the BEGIN line.
- Only the changed lines change. A rewritten line is folded at 75 octets.
- The server runs it over the calendar objects of all other principals
that mention the name or the
urn:uuid:, inboxes excluded, and commits the result with the delete in one transaction. The changed objects count as changes, so clients sync them. - A client that has not synced yet can still PUT its old copy, with the old address, and so invite the new principal. If-Match prevents that for clients that send it.
Client compatibility
What clients need beyond the core RFCs, mostly learned from other servers' issue trackers and from running vdirsyncer, khard, pimsync and python-caldav against the server.
Discovery
- GET and HEAD on
/pim/, principals, homes and collections answer 200 with a short text. RFC 6764 lets clients follow the/.well-knownredirect with GET, and libdav (pimsync) requires a 2xx there. - PROPFIND on
/answers 307 to/pim/, and OPTIONS on/carries the DAV header. python-caldav, given only the server address, asks it forcurrent-user-principal. GET on/stays the web app. - Every
/pimresponse carries theDAVheader, the 401 challenge included. Apple Calendar looks for it on PROPFIND responses. - The Basic challenge names
charset="UTF-8"(RFC 7617), so clients send non-ASCII passwords as UTF-8.
Client properties
- A property the server does not interpret is stored as the client sent
it, as XML, on a collection, a home or a principal. macOS Calendar
PROPPATCHes
default-alarm-vevent-dateonto the calendar home and stops syncing on a 403. macOS Contacts setsme-cardon the address book home. Older Apple clients sendcalendar-free-busy-setwith MKCALENDAR. - A property the server computes is refused with 403, and the response
names
cannot-modify-protected-property. The request stays atomic: the other properties get 424. - One value may be 64 KiB, and one resource may hold 100 such properties. Past that the property gets 507.
- A borrower reads the owner's properties of a lent collection and cannot change them. Admins change those of rooms and resources.
vCard versions
- Address books announce vCard 3.0 only. A client told of 4.0 writes 4.0 groups, which Apple Contacts on the same account cannot read. sabre/dav stopped announcing 4.0 for this reason.
- A 4.0 PUT is still stored as sent. GET returns 3.0 unless
Acceptnamesversion=4.0. The ETag stays that of the stored bytes, soIf-Matchworks with either form. - Converting to 3.0 writes
KIND:groupandMEMBERasX-ADDRESSBOOKSERVER-KINDandX-ADDRESSBOOKSERVER-MEMBER,KIND:orgasX-ABSHOWAS:COMPANY, andPREF=1asTYPE=pref. Converting to 4.0 maps them back. sabre/vobject does the same.
Addresses and logins
calendar-user-address-setlists only the mailto address, withpreferred="1". Apple takes the first href in order unless one is preferred, and a principal URL there would make the attendee not match the user. Scheduling still accepts principal URLs andurn:uuid:addresses.- A principal name becomes the local part of its address with every
character except letters, digits,
-,_and.percent-encoded.%is valid in a local part,@is not. Dots are encoded too when one would lead, trail or repeat. Decoding gives the name back, so resolution uses the same mapping. - A Basic user name with
%that names no account is decoded once. iOS 18.4 and later send@as%40. The throttle counts one attempt.
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 |
address-data or GET without a version |
vCard 3.0 | RFC 6352, 10.4; Apple Contacts needs it |
| Unknown property in PROPPATCH or MKCALENDAR | Stored | RFC 4918 allows dead properties; Apple fails on a 403 |
| 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 |
| MOVE into another owner's collection | 403 | UIDs are unique per owner and a meeting stays in its organizer's calendars; clients fall back to PUT and DELETE |
| A deleted principal in other principals' objects | Tombstone address, SCHEDULE-STATUS 3.7, organized copies cancelled | No RFC covers it; a same-named new principal must not inherit meetings |
| Age in a birthday event | Birth year in the summary, no age | One recurring event cannot carry an age that changes each year |
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
ACLmethod is not implemented. Clients only readcurrent-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_HOMEor~/.cargo. The pinned calcard version inCargo.tomland theCALCARDconstant 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.tsvholds the expected count and a hash per file.oracle.pywrites it.tests/corpus/deviations.tsvoverrides 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.