# 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. - DTSTART is the first of COUNT, also when the rule skips it (RFC 5545, 3.3.10). ical.js counts the same way; dateutil adds DTSTART on top. - 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. - A UTC UNTIL on an all-day series includes a whole day: the later of its UTC date and its date in the floating zone. Clients write midnight in either zone. - 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 - The rules of one series generate at most 1,000,000 occurrences together 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. - 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. - Components nested more than 4 levels below VCALENDAR fail `valid-calendar-data`. The deepest standard case, VEVENT > PARTICIPANT > VLOCATION, has 3. Scheduling and rendering recurse once per level. - Size limits, each failing `valid-calendar-object-resource`. Real data stays far below them. Past them, one object would cost minutes per request. - At most 4 RRULE and EXRULE lines per component, and 50 per VTIMEZONE. - COUNT at most 10,000 for SECONDLY, MINUTELY and HOURLY, 100,000 otherwise. - At most 2,000 ATTENDEE lines per component and 4,000 components per object. - A vCard with more than 10,000 lines fails `valid-address-data`. - XML 1.0 forbids most control characters, also as references. Stored text keeps them. In a response they become U+FFFD, so one object cannot break a whole multistatus. ### 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`. - 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-data` without a version means 3.0 (RFC 6352, 10.4). A 4.0 card is then converted, see "vCard versions" below. calcard 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: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-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. - `itip::respond` answers 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-URL` on 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, and only from an attendee listed there. Otherwise it gets 3.8 and leaves no inbox entry. - 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, if the series has that instance. - 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-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. - 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) 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. - `calendar_meta` reads 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 4 `data:` 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 an `itemN.X-ABLabel` that contains "Anniversary"; that counts too. Only the first date of each kind counts. - The date forms are `19800315`, `1980-03-15`, `--0315` and `--03-15`, with or without a time. calcard reads `--03-15` as a month alone, so the dates are read from the text. Apple's `X-APPLE-OMIT-YEAR` marks 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 `🎂 Name` or `💍 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:-@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-known` redirect 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 for `current-user-principal`. GET on `/` stays the web app. - Every `/pim` response carries the `DAV` header, 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-date` onto the calendar home and stops syncing on a 403. macOS Contacts sets `me-card` on the address book home. Older Apple clients send `calendar-free-busy-set` with 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. - The properties of a principal or a home reach only the accounts that may change them. Other accounts see the live properties only. ### 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 `Accept` names `version=4.0`. The ETag stays that of the stored bytes, so `If-Match` works with either form. - Converting to 3.0 writes `KIND:group` and `MEMBER` as `X-ADDRESSBOOKSERVER-KIND` and `X-ADDRESSBOOKSERVER-MEMBER`, `KIND:org` as `X-ABSHOWAS:COMPANY`, and `PREF=1` as `TYPE=pref`. Converting to 4.0 maps them back. sabre/vobject does the same. ### Addresses and logins - `calendar-user-address-set` lists only the mailto address, with `preferred="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 and `urn: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 with Overwrite onto a meeting | 403 | The meeting would vanish without a CANCEL; clients fall back to PUT and DELETE | | 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 `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 ```bash 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: ```bash cd pimdav/tests/corpus F=$(ls -d ~/.cargo/registry/src/*/calcard-/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: ```bash 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.