CalDAV/CardDAV docs: pimdav README and a README section
- pimdav/README.md: how the crate reads the RFCs for recurrence, time zones, filters, sync and scheduling, a table of unclear or disputed cases, what is not handled, and how the corpus test works - README: "Calendars and contacts" for users and admins with client setup, URLs, sharing levels, invitations, rooms, limits and what is missing - README: pimdav in the feature list, the layout table and `just test` Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MREADME.md
@@ -31,6 +31,9 @@ external services.
- **WebDAV**: mount your folders, or a share, in a file manager. Per-client
app passwords keep the account password out of the mount. See
[WebDAV](#webdav).
- **Calendars and contacts**: CalDAV and CardDAV for calendar and contact
apps. Sharing between users, meeting invitations between users, rooms
and resources. See [Calendars and contacts](#calendars-and-contacts).
- **UI**: light, dark, or system theme. English, German, and French.
Optional single-click open.
- **Passkeys**: sign in with a fingerprint, a face, or a security key.
@@ -210,6 +213,114 @@ Notes:
lock clears after ten minutes, and a server restart clears all of them.
- If two people save the same file at once, the last save wins.
## Calendars and contacts
Calendar and contact apps connect over CalDAV and CardDAV. Examples are
Apple Calendar and Contacts, Thunderbird, and DAVx5 on Android.
| Setting | Value |
|---------|-------|
| Server URL | `https://host`. Apps find the rest through `/.well-known/caldav` and `/.well-known/carddav`. If an app asks for a full URL, use `https://host/pim/`. |
| User name | Your account name |
| Password | An app password, created under Settings → Security |
| URL | Content |
|-----|---------|
| `/pim/principals/<name>/` | An account, room or resource |
| `/pim/calendars/<name>/` | Your calendars, the scheduling inbox, and calendars lent to you |
| `/pim/addressbooks/<name>/` | Your address books, the system address book, and address books lent to you |
Every account starts with a calendar named "Calendar" and an address book
named "Contacts". New invitations land in the oldest calendar that takes
events, so that calendar cannot be deleted.
The **system address book** (`system`) lists every active account, room
and resource on the server. It is read-only and built by the server.
### Sharing
You can lend a calendar or address book to another account. It then shows
up in that account's list as "Name (owner)". There are three levels:
| Level | The other account may |
|-------|-----------------------|
| `ro` | Read |
| `rw` | Also add, change and delete entries |
| `rw+schedule` | Also send and answer invitations in your name |
With plain `rw`, a change that would send an invitation or an answer in
your name is refused. With `rw+schedule`, the messages name the other
account as the sender (`SENT-BY`). Only the owner can rename a collection
or change its color.
The web UI has no page for this yet. The JSON API is
`GET /api/pim/collections`, then `GET` or `POST
/api/pim/collections/<id>/shares` with `{"user": "<name>", "mode":
"rw"}`, and `DELETE /api/pim/collections/<id>/shares/<user id>` to end a
loan.
### Invitations
Invitations work between accounts, rooms and resources of this server.
The server delivers them itself. Every invitee gets a copy in their
calendar and a message in their inbox. Answers flow back to the organizer
the same way.
The server sends no email. Addresses use the reserved `.invalid` domain,
so nothing can reach anyone outside by mistake:
| Principal | Address |
|-----------|---------|
| Account | `<name>@filebrowser.invalid` |
| Room | `<name>@rooms.filebrowser.invalid` |
| Resource | `<name>@resources.filebrowser.invalid` |
An outside address in an invitation is kept, but marked as not delivered
(status 5.2). Apps find the people on the server through the system
address book or their attendee search.
### Rooms and resources
Admins manage rooms and resources with `GET` and `POST /api/admin/rooms`
and `PUT` and `DELETE /api/admin/rooms/<id>`. A room's name is fixed,
because it is part of its address. Its display name can change. Rooms and
accounts share one set of names.
Everyone can read a room's bookings. The server answers a room's
invitations itself. An instance that overlaps an existing booking is
declined. Everything else is accepted. For a repeating meeting, only the
instances that collide are declined.
### Limits
- One calendar entry or contact can be 10 MiB. An XML request can be
1 MiB.
- An inbox keeps its newest 100 messages. The meetings themselves stay in
the calendar.
- A repeating event returned as single instances can have at most 10,000
in one request.
- A repeating rule is followed for at most 1,000,000 occurrences per
request. Only extreme rules reach this, such as every minute for years.
Such an event counts as matching every time range.
- A room checks conflicts for the next 366 days. Later instances are
accepted without a check.
- One lock serializes all writes of calendar entries and contacts on the
server. That is fine for a small server.
- The system address book has no change history. After any change to
accounts or rooms, apps download it again in full.
Not supported:
- Email invitations (iMIP), in either direction.
- Apple's delegation (calendar proxies) and the `calendarserver-sharing`
invite flow. Sharing works through the API above.
- Delegating a meeting seat to someone else, and progress replies on
assigned tasks.
- Calendars other than the Gregorian one (RFC 7529).
[`pimdav/README.md`](pimdav/README.md) describes how the server reads the
RFCs in detail, including every case where they leave room.
## Development
Requirements: Rust 1.90 or newer with the `wasm32-unknown-unknown` target,
@@ -228,7 +339,7 @@ cargo install trunk
| `just dev-web` | Frontend on :8080 with hot reload, proxying `/api` to :8081 |
| `just build` | Release binary with the frontend embedded, `target/release/filebrowser-ng` |
| `just run` | Build and run the release binary against the dev folders |
| `just test` | Server and API type tests |
| `just test` | Server, API type and pimdav tests |
| `just e2e` | Build the real frontend and run the server suite against the embedded build |
| `just lint` | `cargo fmt --check` and clippy with warnings denied |
| `just reset-db` | Delete the dev database |
@@ -240,6 +351,7 @@ cargo install trunk
| `server` | axum HTTP server, SQLite via rusqlite, file and archive handling, embedded frontend |
| `web` | Leptos client-side app compiled to WebAssembly |
| `api-types` | Request and response types plus route constants shared by both |
| `pimdav` | CalDAV and CardDAV logic without I/O, see [its README](pimdav/README.md) |
The frontend build lands in `web/dist`, is copied to `server/dist`, and is
compiled into the binary with the `embedded` feature. `web/cm6.js` is the
Apimdav/README.md
@@ -0,0 +1,431 @@
# 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 |
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.
### 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 owns SENT-BY. Every write of a scheduling object sets it to
the writer, or removes it when the owner writes.
- 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.
- Only instances in the next 366 days are checked. Past instances and
later ones are accepted unchecked.
## 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 |
## 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.
- **DTSTAMP on organizer objects.** Objects are stored as sent, even
without DTSTAMP.
- **Reports on a single object or a home.** Only collections take
calendar and address book reports. Others fail with `supported-report`.
## 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-<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:
```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.