CalDAV: room horizon, quiet SENT-BY, DTSTAMP on PUT

- Rooms check a series without end for 2 years and anything else to its
  end, at most 10 years (was a flat 366 days)
- SENT-BY is set only when a write sends a message; other writes keep
  their bytes and their ETag
- A calendar PUT without DTSTAMP gets one, inserted as one line with all
  other bytes unchanged (`object::with_dtstamp`)
- Docs: the new rules and limits, and sharing through the protocol
  (ACL method, calendarserver-sharing) listed as not handled

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commita5a298af49b6c04189db7a50d2d3f57e5088aeae
Parent3642717
9 files changed, 240 insertions(+), 27 deletions(-)
▾MREADME.md
@@ -302,8 +302,9 @@ instances that collide are declined.
- 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.
- A room checks a repeating meeting without end for the next two years.
Anything else is checked to its end, at most ten years ahead. 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
@@ -312,8 +313,10 @@ instances that collide are declined.
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.
- Sharing from inside a calendar app: neither the `ACL` method nor
Apple's `calendarserver-sharing` invite flow. Sharing works through the
API above, and a lent calendar appears without an accept step.
- Apple's delegation (calendar proxies).
- Delegating a meeting seat to someone else, and progress replies on
assigned tasks.
- Calendars other than the Gregorian one (RFC 7529).
▾Mpimdav/README.md
@@ -161,6 +161,10 @@ rule with BYMONTH, that is `1TU` to `5TU`. Otherwise it is `1TU` to `53TU`.
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
@@ -306,8 +310,11 @@ copy carries the status of its last reply on the ORGANIZER.
### 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.
- 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.
@@ -331,8 +338,12 @@ copy carries the status of its last reply on the ORGANIZER.
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.
- 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.
## Where the RFCs are unclear or implementations differ
@@ -371,10 +382,18 @@ copy carries the status of its last reply on the ORGANIZER.
- **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`.
- **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
▾Mpimdav/src/itip.rs
@@ -12,7 +12,7 @@ use calcard::icalendar::{
ICalendarMethod, ICalendarParameter, ICalendarParameterName, ICalendarParameterValue,
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue, Uri,
};
use chrono::{DateTime, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use xmltree::Element;
use crate::expand::expand;
@@ -120,7 +120,8 @@ pub fn prepare(
/// Names who acted for the owner: `SENT-BY` on the owner's ORGANIZER and
/// ATTENDEE properties when `sender` is someone else, none when it is the
/// owner. The server owns the parameter, so a stale one never survives.
/// owner. Only for writes that send a message, so every message names its
/// real sender.
pub fn stamp_sender(cal: &mut ICalendar, owner: Is, sender: Option<&str>) {
for c in cal.components.iter_mut().filter(|c| is_scheduled(c)) {
for e in &mut c.entries {
@@ -141,6 +142,22 @@ pub fn stamp_sender(cal: &mut ICalendar, owner: Is, sender: Option<&str>) {
}
}
/// How far ahead a room checks an invitation against its bookings. A
/// series without end has infinite instances, so it is checked for two years
/// and accepted beyond. Anything else is checked to its end, at most ten
/// years ahead.
pub fn answer_horizon(copy: &ICalendar) -> TimeDelta {
let endless = copy.components.iter().any(|c| {
is_scheduled(c)
&& !c.has_property(&ICalendarProperty::RecurrenceId)
&& c.properties(&ICalendarProperty::Rrule).any(|e| {
matches!(e.values.first(), Some(ICalendarValue::RecurrenceRule(r))
if r.count.is_none() && r.until.is_none())
})
});
TimeDelta::days(if endless { 731 } else { 3653 })
}
/// The answer of a room or resource to the invitation in its copy:
/// ACCEPTED, and DECLINED where an instance in `window` overlaps `taken`. A
/// declined instance of a series gets an override of its own.
▾Mpimdav/src/object.rs
@@ -2,6 +2,7 @@
use calcard::icalendar::{ICalendarComponentType, ICalendarProperty};
use calcard::{Entry, Parser};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::xml::{CALDAV, CARDDAV, el};
@@ -104,3 +105,60 @@ pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
}
Ok(card.uid().map(str::to_string))
}
/// `data` with `DTSTAMP:<now>` inserted after the BEGIN line of each VEVENT,
/// VTODO, VJOURNAL and VFREEBUSY that lacks it (RFC 5545 requires it).
/// Inserts text instead of re-serializing, so every other byte stays.
/// `None` if nothing was missing.
pub fn with_dtstamp(data: &[u8], now: DateTime<Utc>) -> Option<Vec<u8>> {
const STAMPED: [&[u8]; 4] = [b"VEVENT", b"VTODO", b"VJOURNAL", b"VFREEBUSY"];
let lines: Vec<&[u8]> = data.split_inclusive(|&b| b == b'\n').collect();
// (component, index of its BEGIN line, has DTSTAMP)
let mut open: Vec<(&[u8], usize, bool)> = Vec::new();
let mut missing = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.first().is_some_and(|b| *b == b' ' || *b == b'\t') {
continue;
}
let line = line.trim_ascii_end();
let name_end = line
.iter()
.position(|b| *b == b':' || *b == b';')
.unwrap_or(line.len());
let (name, value) = (
&line[..name_end],
line.get(name_end + 1..).unwrap_or_default(),
);
if name.eq_ignore_ascii_case(b"BEGIN") {
open.push((value, i, false));
} else if name.eq_ignore_ascii_case(b"END") {
if let Some((comp, begin, false)) = open.pop()
&& STAMPED.iter().any(|s| comp.eq_ignore_ascii_case(s))
{
missing.push(begin);
}
} else if name.eq_ignore_ascii_case(b"DTSTAMP")
&& let Some(top) = open.last_mut()
{
top.2 = true;
}
}
if missing.is_empty() {
return None;
}
let stamp = now.format("DTSTAMP:%Y%m%dT%H%M%SZ").to_string();
let mut out = Vec::with_capacity(data.len() + missing.len() * 28);
for (i, line) in lines.iter().enumerate() {
out.extend_from_slice(line);
if missing.contains(&i) {
let lf_only = line.ends_with(b"\n") && !line.ends_with(b"\r\n");
let eol: &[u8] = if lf_only { b"\n" } else { b"\r\n" };
if !line.ends_with(b"\n") {
out.extend_from_slice(eol);
}
out.extend_from_slice(stamp.as_bytes());
out.extend_from_slice(eol);
}
}
Some(out)
}
▾Mpimdav/tests/protocol.rs
@@ -159,3 +159,26 @@ fn vcards() {
Err(Invalid::AddressData)
);
}
#[test]
fn missing_dtstamp_is_inserted() {
let now = chrono::DateTime::from_timestamp(1_790_000_000, 0).unwrap();
let sent = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a\r\nDESCRIPTION:long\r\n line\r\n\
BEGIN:VALARM\r\nDTSTAMP:20260101T000000Z\r\nEND:VALARM\r\nEND:VEVENT\r\n\
BEGIN:VTODO\r\nDTSTAMP:20260101T000000Z\r\nUID:b\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
let got = object::with_dtstamp(sent.as_bytes(), now).unwrap();
// The VALARM's DTSTAMP does not count for the VEVENT around it.
let want = sent.replacen(
"BEGIN:VEVENT\r\n",
"BEGIN:VEVENT\r\nDTSTAMP:20260921T141320Z\r\n",
1,
);
assert_eq!(String::from_utf8(got).unwrap(), want);
assert_eq!(object::with_dtstamp(want.as_bytes(), now), None);
let lf = "BEGIN:VCALENDAR\nBEGIN:VJOURNAL\nUID:c\nEND:VJOURNAL\nEND:VCALENDAR\n";
let got = object::with_dtstamp(lf.as_bytes(), now).unwrap();
assert_eq!(
String::from_utf8(got).unwrap(),
lf.replace("VJOURNAL\nUID", "VJOURNAL\nDTSTAMP:20260921T141320Z\nUID")
);
}
▾Mserver/src/api/pim.rs
@@ -1463,6 +1463,11 @@ impl Cx<'_> {
Ok(v) => v,
Err(invalid) => return Ok(error(StatusCode::FORBIDDEN, invalid.condition())),
};
let stamped = match kind {
PimKind::Calendar => object::with_dtstamp(&data, chrono::Utc::now()),
PimKind::AddressBook => None,
};
let data = stamped.as_deref().unwrap_or(&data);
let _lock = pim_schedule::LOCK.lock().await;
let db = &self.state.db;
@@ -1485,7 +1490,7 @@ impl Cx<'_> {
let owner = self.owner(&col, &dir).await?;
let w = self.writer(&owner, access);
let old = current.as_ref().map(|(_, d)| d.as_slice());
match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, &data).await? {
match pim_schedule::put(self.state, &dir, &w, (col.id, name), old, data).await? {
Ok(s) => s,
Err(condition) => return Ok(error(StatusCode::FORBIDDEN, condition)),
}
@@ -1518,7 +1523,7 @@ impl Cx<'_> {
};
let mut r = status(code);
// Only when the stored bytes are the request bytes (RFC 4791, 5.3.4).
if !stored.changed {
if !stored.changed && stamped.is_none() {
r.headers_mut()
.insert(ETAG, etag.parse().expect("hex is a valid header"));
}
▾Mserver/src/api/pim_schedule.rs
@@ -8,7 +8,7 @@
//! Rooms and resources answer at once, from their own bookings. The outbox
//! answers free-busy requests from the recipients' calendars.
use chrono::{DateTime, TimeDelta, Utc};
use chrono::{DateTime, Utc};
use percent_encoding::percent_decode_str;
use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
use pimdav::filter::TimeRange;
@@ -43,10 +43,6 @@ const NO_ROUTE: &str = "5.2";
/// The recipient has no calendar for the component.
const REFUSED: &str = "5.3";
/// How far ahead a room checks a series against its bookings. Later
/// instances are accepted unchecked.
const ANSWER_HORIZON: TimeDelta = TimeDelta::days(366);
/// Who writes into a calendar, as far as scheduling cares.
pub(crate) struct Writer<'a> {
pub owner: &'a PimPrincipal,
@@ -179,10 +175,13 @@ pub(crate) async fn put(
(Role::Organizer, _) => {
let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer));
let (mut store, force) = itip::prepare(old, &sent, &owns);
itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
let mut messages = itip::messages(old, Some(&store), &owns, &force, now);
if !messages.is_empty() && !w.may_schedule {
return Ok(Err(w.refused("schedule-send-invite")));
if !messages.is_empty() {
if !w.may_schedule {
return Ok(Err(w.refused("schedule-send-invite")));
}
itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
messages = itip::messages(old, Some(&store), &owns, &force, now);
}
// Rooms answer first, so the others' copies carry their answers.
if answer_rooms(state, dir, owner, &mut store, &messages, &mut ops, now).await? {
@@ -197,16 +196,16 @@ pub(crate) async fn put(
}
(Role::Attendee, Some(Role::Attendee)) => {
let old = old.as_ref().expect("an attendee role needs the old object");
let mut incoming = sent.clone();
itip::stamp_sender(&mut incoming, &owns, w.sent_by.as_deref());
let (mut store, reply) = match itip::attend(old, incoming, &owns, now) {
let (mut store, reply) = match itip::attend(old, sent.clone(), &owns, now) {
Ok(v) => v,
Err(refused) => return Ok(Err(refused.condition())),
};
if let Some(reply) = reply {
if let Some(mut reply) = reply {
if !w.may_schedule {
return Ok(Err(w.refused("schedule-send-reply")));
}
itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
itip::stamp_sender(&mut reply.cal, &owns, w.sent_by.as_deref());
let status = reply_to(state, dir, owner, &reply, &mut ops).await?;
itip::set_organizer_status(&mut store, status);
}
@@ -370,7 +369,7 @@ async fn answer_rooms(
continue;
};
let is_room = dir.is(room.id);
let window = now..now + ANSWER_HORIZON;
let window = now..now + itip::answer_horizon(&received);
let taken = busy_of(state, dir, room, &window, Some(&uid)).await?;
let floating = floating_of(calendar.timezone.as_deref());
let answer = itip::auto_answer(&received, &is_room, &taken, &window, &floating);
▾Mserver/tests/api_pim.rs
@@ -392,6 +392,21 @@ async fn make_and_patch_collections() {
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn missing_dtstamp_is_added() {
let (env, auth) = setup().await;
let obj = format!("{CAL}s.ics");
let sent = event("s", "One").replace("DTSTAMP:20260101T000000Z\r\n", "");
let r = req(&env, "PUT", &obj, &auth, &[], &sent).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
assert_eq!(r.header("etag"), None);
let stored = req(&env, "GET", &obj, &auth, &[], "").await.text();
let (head, rest) = stored.split_once("BEGIN:VEVENT\r\nDTSTAMP:").unwrap();
let (stamp, tail) = rest.split_once("\r\n").unwrap();
assert_eq!(stamp.len(), 16, "{stored}");
assert_eq!(format!("{head}BEGIN:VEVENT\r\n{tail}"), sent);
}
#[tokio::test]
async fn calendar_objects() {
let (env, auth) = setup().await;
▾Mserver/tests/api_pim_schedule.rs
@@ -603,6 +603,67 @@ async fn rooms_answer_from_their_bookings() {
assert_eq!((answers("ACCEPTED"), answers("DECLINED")), (1, 1), "{org}");
}
#[tokio::test]
async fn rooms_check_series_to_their_horizon() {
let pim = Pim::new().await;
pim.room().await;
let org = |user: &'static str, uid: &'static str| {
let pim = &pim;
async move { pim.get(user, &format!("{}{uid}.ics", cal(user))).await }
};
let declined = |org: &str| {
org.lines()
.filter(|l| l.ends_with(ROOM) && l.contains("PARTSTAT=DECLINED"))
.count()
};
let weekly = |uid: &str, who: &str, start: &str, rule: &str| {
booking(
uid,
who,
start,
&format!("RRULE:FREQ=WEEKLY;{rule}\r\n"),
&[ROOM],
)
};
for (uid, start) in [
("c1", future(800, 9)),
("c2", future(800, 14)),
("c3", future(3700, 9)),
] {
pim.put_ok(
"carol",
&format!("{}{uid}.ics", cal("carol")),
&booking(uid, "carol", &start, "", &[ROOM]),
)
.await;
}
// A bounded series is checked to its end, past two years.
pim.put_ok(
"bob",
&format!("{}b1.ics", cal("bob")),
&weekly("b1", "bob", &future(786, 9), "COUNT=3"),
)
.await;
assert_eq!(declined(&org("bob", "b1").await), 1);
// A series without end is checked for two years only.
pim.put_ok(
"alice",
&format!("{}a1.ics", cal("alice")),
&weekly("a1", "alice", &future(786, 14), "INTERVAL=1"),
)
.await;
assert_eq!(declined(&org("alice", "a1").await), 0);
// Ten years is the limit even for a bounded series.
pim.put_ok(
"bob",
&format!("{}b2.ics", cal("bob")),
&weekly("b2", "bob", &future(3693, 9), "COUNT=2"),
)
.await;
assert_eq!(declined(&org("bob", "b2").await), 0);
}
#[tokio::test]
async fn sharees_schedule_only_when_allowed() {
let pim = Pim::new().await;
@@ -654,6 +715,19 @@ async fn sharees_schedule_only_when_allowed() {
let (_, copy) = pim.copy("carol").await;
assert!(unfold(&copy.text()).contains(&sent_by), "{}", copy.text());
// An edit that sends nothing is stored as sent: alice's own edit keeps
// bob's SENT-BY, and the PUT keeps its ETag.
let s1 = format!("{}s1.ics", cal("alice"));
let edited = pim.req("alice", "GET", &s1, &[], "").await.text().replacen(
"UID:s1\r\n",
"UID:s1\r\nX-NOTE:quiet\r\n",
1,
);
let r = pim.req("alice", "PUT", &s1, &[], &edited).await;
assert!(r.status.is_success(), "{}", r.text());
assert!(r.header("etag").is_some(), "{edited}");
assert_eq!(pim.req("alice", "GET", &s1, &[], "").await.text(), edited);
// Answering for alice needs it too.
lend("rw").await;
pim.put_ok(