CalDAV/CardDAV review fixes, round 7

- Attendee checks compare start, rules and end time, with nominal
  DURATION days, so clients that rewrite DURATION as DTEND or add a
  missing end pass; overrides must name a real instance and keep the
  organizer's STATUS, except task progress
- A new override that changes the length is a reschedule; dropping a
  declined instance replies with the series answer; a reordered save
  sends nothing
- Time zones are built only for the TZIDs in use; THISANDFUTURE lookups
  use a binary search; rules with BYSETPOS do not skip ahead; a stale
  range override past the series cuts nothing
- Replies with many overrides expand once
- Objects with an unclosed component are refused, and feeds skip cut-off
  components
- Import places overrides by their master's start and time zone without
  expanding
- Contact dates stay linear; limit-recurrence-set keeps overrides whose
  original instance overlaps; expanded instances are fully in UTC; busy
  feeds keep a free master that a busy range override needs; vCard 3.0
  gets plain TEL numbers and Apple birthdays without a year
- Dead properties are capped at 256 KiB per resource; PROPPATCH follows
  document order; new object names are capped; 405 answers carry Allow;
  a user and room name race answers 409
- The calendar settings choose which calendar receives invitations;
  override instances are answered as single events; feeds are capped at
  50 per collection and cannot be born expired; import parsing runs off
  the async workers
- README no longer suggests sub-path hosting for CalDAV
- Tests for each change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit604762580e7a865e5ebbd77055d1f2f2bde839ef
Parentaacdd4f
27 files changed, 1301 insertions(+), 196 deletions(-)
▾MREADME.md
@@ -234,9 +234,6 @@ Apple Calendar and Contacts, Thunderbird, and DAVx5 on Android.
- Apple Calendar and Contacts only connect over HTTPS, and iOS rejects a
self-signed certificate without SAN entries. Put the server behind a
reverse proxy with a real certificate.
- Apps look for `/.well-known/caldav` at the root of the host. Under a
sub-path such as `https://host/files/` that lookup fails, so give apps
the full URL there (`https://host/files/pim/`).
- Thunderbird: set the calendar's email identity to "None". Otherwise
Thunderbird names your real email as organizer. The server does not
know that address, so it invites no one. Thunderbird sends no email
▾Mapi-types/src/lib.rs
@@ -652,6 +652,10 @@ pub struct UpdatePimCollection {
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transparent: Option<bool>,
/// `true` makes this own event calendar the one that receives
/// invitations.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_default: Option<bool>,
}
/// One occurrence of an event, task or journal entry: `GET {PIM_INSTANCES}`.
@@ -727,6 +731,9 @@ pub struct PimEventDetail {
pub rrule: Option<String>,
pub organizer: Option<PimPerson>,
pub attendees: Vec<PimAttendee>,
/// The instance has a component of its own (RECURRENCE-ID).
#[serde(default)]
pub is_override: bool,
/// The owner's PARTSTAT on the series' master, when they attend it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series_partstat: Option<String>,
@@ -867,7 +874,7 @@ pub struct AdminPimLink {
pub kind: PimCollectionKind,
pub owner_id: i64,
pub owner_name: String,
/// Whether the owner can still sign in. A disabled owner's feeds stay live.
/// Whether the owner can still sign in. A disabled owner's feeds answer 404.
pub owner_active: bool,
}
▾Mpimdav/src/bundle.rs
@@ -6,11 +6,11 @@
use std::collections::{HashMap, HashSet};
use calcard::icalendar::{ICalendar, ICalendarProperty};
use chrono::TimeDelta;
use calcard::icalendar::{ICalendar, ICalendarComponentType, ICalendarProperty};
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use crate::expand::{expand, stamp};
use crate::expand::stamp;
use crate::text::{
escape_text, fold, logical_lines, name, param, param_parts, unescape_text, unfold, value,
value_start,
@@ -56,7 +56,12 @@ pub fn calendar(objects: &[&str], name: Option<&str>, detail: Detail) -> String
let mut seen = HashSet::new();
for object in objects {
for cal in top_blocks(object, "VCALENDAR") {
let children = split_level(inner(&cal.lines)).1;
// A component cut off before its END would swallow the ones after.
let children: Vec<Block> = split_level(inner(&cal.lines))
.1
.into_iter()
.filter(Block::complete)
.collect();
for z in children.iter().filter(|c| c.name == "VTIMEZONE") {
if z.prop("TZID").is_some_and(|id| seen.insert(id)) {
push_lines(&mut zones, &z.lines);
@@ -95,7 +100,10 @@ pub fn calendar(objects: &[&str], name: Option<&str>, detail: Detail) -> String
pub fn cards(objects: &[&str]) -> String {
let mut out = String::new();
for object in objects {
for card in top_blocks(object, "VCARD") {
for card in top_blocks(object, "VCARD")
.into_iter()
.filter(Block::complete)
{
push_lines(&mut out, &card.lines);
}
}
@@ -227,16 +235,35 @@ pub fn split_calendar(
}
}
}
let master_texts: Vec<String> = groups.iter().map(|g| g.1.clone()).collect();
let zones_of = |ids: &HashSet<String>| -> String {
ids.iter()
.filter_map(|id| zones.get(id))
.map(String::as_str)
.collect()
};
for (c, tzids) in overrides {
let key = (c.prop("UID").unwrap_or_default(), c.name.clone());
let group = match masters.get(&key).map(Vec::as_slice) {
Some([only]) => Some(*only),
Some(list) => list
.iter()
.copied()
.find(|&i| has_instance(&master_texts[i], &c, &zones))
.or(list.first().copied()),
Some(list) => {
let rid = instant(
&(zones_of(&tzids) + &lines_text(&c.lines)),
ICalendarProperty::RecurrenceId,
);
// ponytail: the master that started last before the instance.
// Interleaved series of one UID can pick wrong; expanding each
// candidate is exact but costs a full expansion per override.
list.iter()
.copied()
.filter_map(|i| {
let (_, text, ids) = &groups[i];
let start = instant(&(zones_of(ids) + text), ICalendarProperty::Dtstart)?;
Some((start, i)).filter(|(s, _)| rid.is_some_and(|r| *s <= r))
})
.max()
.map(|(_, i)| i)
.or(list.first().copied())
}
None => None,
};
add(&mut groups, &c, tzids, group);
@@ -439,8 +466,11 @@ fn blocks_time(e: &Block) -> bool {
/// later instances too. The UID becomes a hash of it, because UIDs often
/// hold host names or mail addresses.
fn push_busy(out: &mut String, events: &[&Block]) {
let (kept, free): (Vec<&&Block>, Vec<&&Block>) =
events.iter().partition(|e| blocks_time(e) || ranged(e));
// A free master stays, as free, when a busy range override needs its rule.
let busy_range = events.iter().any(|e| ranged(e) && blocks_time(e));
let (kept, free): (Vec<&&Block>, Vec<&&Block>) = events.iter().partition(|e| {
blocks_time(e) || ranged(e) || (busy_range && e.prop("RECURRENCE-ID").is_none())
});
let mut exdates: Vec<String> = free
.iter()
.filter_map(|e| {
@@ -497,38 +527,25 @@ fn exdate(rid: &str) -> String {
format!("EXDATE{params}:{}\r\n", &line[start..])
}
/// Whether the series in `master` has an instance at the RECURRENCE-ID of
/// `over`.
fn has_instance(master: &str, over: &Block, zones: &HashMap<String, String>) -> bool {
let cal = |body: &str| {
let mut text = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n");
text.extend(zones.values().map(String::as_str));
text.push_str(body);
text.push_str("END:VCALENDAR\r\n");
ICalendar::parse(&text).ok()
};
let (Some(series), Some(over_cal)) = (cal(master), cal(&lines_text(&over.lines))) else {
return false;
};
let zone_set = Zones::new(&over_cal, Zone::Utc);
let Some(rid) = over_cal
/// The first `prop` of the components in `body`, with the VTIMEZONEs it
/// names, as an instant.
fn instant(body: &str, prop: ICalendarProperty) -> Option<DateTime<Utc>> {
let cal = ICalendar::parse(format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"
))
.ok()?;
let zones = Zones::new(&cal, Zone::Utc);
let e = cal
.components
.iter()
.find_map(|c| c.property(&ICalendarProperty::RecurrenceId))
.and_then(|e| {
stamp(
&zone_set,
e.values.first()?.as_partial_date_time()?,
e.tz_id(),
.filter(|c| {
!matches!(
c.component_type,
ICalendarComponentType::VTimezone
| ICalendarComponentType::Standard
| ICalendarComponentType::Daylight
)
})
.map(|s| s.utc())
else {
return false;
};
let window = rid - TimeDelta::days(1)..rid + TimeDelta::days(1);
expand(&series, window, Zone::Utc)
.instances
.iter()
.any(|i| i.recurrence_id == Some(rid))
.find_map(|c| c.property(&prop))?;
stamp(&zones, e.values.first()?.as_partial_date_time()?, e.tz_id()).map(|s| s.utc())
}
▾Mpimdav/src/contact.rs
@@ -1,6 +1,8 @@
//! What the server derives from contacts: their photos, and their birthdays
//! and anniversaries as calendar events.
use std::collections::HashSet;
use calcard::vcard::{VCard, VCardProperty, VCardValue};
use chrono::NaiveDate;
use sha2::{Digest, Sha256};
@@ -40,15 +42,12 @@ pub fn dates(vcard: &str, key: &str) -> Vec<(String, String)> {
.unwrap_or_default();
// Apple writes an anniversary as X-ABDATE, labelled by a sibling line of
// the same group.
let apple_anniversary = |l: &str| {
let g = group(l);
g.is_some()
&& lines.iter().any(|o| {
group(o) == g
&& prop(o) == "X-ABLABEL"
&& value(o).to_ascii_lowercase().contains("anniversary")
})
};
let labelled: HashSet<String> = lines
.iter()
.filter(|o| prop(o) == "X-ABLABEL" && value(o).to_ascii_lowercase().contains("anniversary"))
.filter_map(|o| group(o))
.collect();
let apple_anniversary = |l: &str| group(l).is_some_and(|g| labelled.contains(&g));
let first = |wanted: &dyn Fn(&str) -> bool| {
lines
.iter()
▾Mpimdav/src/expand.rs
@@ -417,7 +417,8 @@ fn expand_group(
// The latest THISANDFUTURE override before this instance moves it by
// the same offset and gives it the override's length. The offset is
// wall-clock time, so later instances keep their local time across DST.
let instance = match future.iter().rev().find(|f| f.0 <= *key) {
let latest = future.partition_point(|f| f.0 <= *key).checked_sub(1);
let instance = match latest.map(|i| &future[i]) {
Some((_, rid, t)) => {
let shift = mz.to_local(t.start.utc()) - mz.to_local(*rid);
let start = mz.to_utc(add_local(member.local, shift));
@@ -489,7 +490,8 @@ fn occurrences_capped(
_ => 0,
};
let mut first = m.start.local;
if unit > 0 && rule.count.is_none() {
// BYSETPOS picks from a whole period, which a mid-period start would cut.
if unit > 0 && rule.count.is_none() && rule.bysetpos.is_empty() {
let step = unit * i64::from(rule.interval.unwrap_or(1).max(1));
let behind = (from_local - first).num_seconds();
if behind > 0 {
@@ -564,6 +566,19 @@ fn nominal(d: &ICalendarDuration) -> Length {
}
}
/// The original start and end of the instance at `rid` of the series in
/// component `master`.
pub(crate) fn original(
cal: &ICalendar,
zones: &Zones,
master: usize,
rid: &Stamp,
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
let t = Timing::of(cal, zones, master)?;
let start = t.utc_of(rid);
Some((start, t.end_at(t.start.zone.to_local(start), start)))
}
pub(crate) fn stamp(zones: &Zones, v: &PartialDateTime, tzid: Option<&str>) -> Option<Stamp> {
let dt = v.to_date_time()?;
let date = v.hour.is_none();
▾Mpimdav/src/itip.rs
@@ -418,28 +418,34 @@ pub fn attend(
me: Is,
now: DateTime<Utc>,
) -> Result<(ICalendar, Option<Message>), Refused> {
let old = Obj::new(old);
let old_cal = old;
let old = Obj::new(old_cal);
if !old.organizer_schedules() {
return Ok((new, None));
}
let mut next = Obj::new(&new);
let master = old.master();
let added: Vec<i64> = next
.comps()
.filter_map(|c| next.key(&c.c))
.filter(|k| old.find(Some(*k)).is_none())
.collect();
let instances = old.instances(old_cal, &added);
for c in next.comps() {
let key = next.key(&c.c);
match old.find(key) {
Some(oc) => {
// The end, not its property: clients rewrite DURATION as DTEND.
let same_times = [
ICalendarProperty::Dtstart,
ICalendarProperty::Dtend,
ICalendarProperty::Duration,
ICalendarProperty::Due,
ICalendarProperty::Rrule,
ICalendarProperty::Rdate,
ICalendarProperty::Exrule,
]
.iter()
.all(|p| old.times(&oc.c, p) == next.times(&c.c, p));
.all(|p| old.times(&oc.c, p) == next.times(&c.c, p))
&& old.end(&oc.c) == next.end(&c.c);
let kept_exdates = old
.times(&oc.c, &ICalendarProperty::Exdate)
.is_subset(&next.times(&c.c, &ICalendarProperty::Exdate));
@@ -453,7 +459,10 @@ pub fn attend(
}
// An instance the attendee overrides, to set its own status.
None => {
if !key.is_some_and(|k| master.is_some() && old.plain(&next, &c.c, k)) {
let instance = |k: i64| instances.as_ref().is_none_or(|s| s.contains(&k));
if !key
.is_some_and(|k| master.is_some() && instance(k) && old.plain(&next, &c.c, k))
{
return Err(Refused::AttendeeChange);
}
}
@@ -472,15 +481,21 @@ pub fn attend(
.filter_map(|v| next.at(v.as_partial_date_time()?, e.tz_id()))
})
.collect();
let mut reverted: Vec<(i64, Option<String>)> = Vec::new();
for oc in old.comps() {
let key = old.key(&oc.c);
let dropped = |k: i64| {
master.is_none()
|| !is_range(&oc.c) && (exdated.contains(&k) || old.plain(&old, &oc.c, k))
};
if next.find(key).is_none() && !key.is_some_and(dropped) {
if next.find(key).is_some() {
continue;
}
let plain = |k: i64| old.plain(&old, &oc.c, k);
let allowed =
|k: i64| master.is_none() || !is_range(&oc.c) && (exdated.contains(&k) || plain(k));
if !key.is_some_and(allowed) {
return Err(Refused::AttendeeChange);
}
if let Some(k) = key.filter(|k| master.is_some() && !exdated.contains(k)) {
reverted.push((k, own_partstat(&oc.c, me)));
}
}
// The state of the others and of the organizer is the server's. A new
@@ -523,6 +538,16 @@ pub fn attend(
replied.push(reply_part(c, me));
}
}
// A dropped override falls back to the series' answer for that instance.
for (k, held) in reverted {
let Some(inst) = DateTime::from_timestamp(k, 0).and_then(|t| next.single(t, &Zone::Utc))
else {
continue;
};
if own_partstat(&inst.c, me).is_some_and(|now| Some(now) != held) {
replied.push(reply_part(&inst, me));
}
}
if let Some(m) = next.master() {
let before = master.map_or_else(HashSet::new, |om| {
old.times(&om.c, &ICalendarProperty::Exdate)
@@ -739,6 +764,13 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
let mut parts: Vec<&Node> = rep.comps().collect();
// In order: a range narrowed for one instance moves on to the next.
parts.sort_by_key(|c| rep.key(&c.c));
let missing: Vec<i64> = parts
.iter()
.filter_map(|c| rep.key(&c.c))
.filter(|k| next.position(Some(*k)).is_none())
.collect();
// A cut-short expansion over all parts falls back to one per part.
let shared = next.instances(org, &missing);
for rc in parts {
let key = rep.key(&rc.c);
let at = match next.position(key) {
@@ -758,7 +790,11 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
else {
continue;
};
if !next.occurs(org, at, t, &Zone::Utc) {
let known = match &shared {
Some(set) => set.contains(&t),
None => next.instances(org, &[t]).is_none_or(|set| set.contains(&t)),
};
if !known {
continue;
}
let Some(inst) = next.single(at, &Zone::Utc) else {
@@ -850,7 +886,11 @@ fn guard(
c.c.property(&ICalendarProperty::Dtstart)
.and_then(|e| next.instant(e));
let key = next.key(&c.c);
key.is_none() || start != key.map(|k| o.moved(k))
key.is_none()
|| start != key.map(|k| o.moved(k))
|| base_of(key).is_some_and(|b| {
next.end(&c.c) != start.and_then(|s| o.end_from(&b.c, s))
})
}
},
None => true,
@@ -917,15 +957,10 @@ fn guard(
/// Whether a change moves instances in time (RFC 6638, 3.2.8). Shortening a
/// series or excluding instances does not.
fn rescheduled(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bool {
let moved = [
ICalendarProperty::Dtstart,
ICalendarProperty::Dtend,
ICalendarProperty::Duration,
ICalendarProperty::Due,
ICalendarProperty::Rdate,
]
.iter()
.any(|p| old.times(oc, p) != new.times(nc, p));
let moved = [ICalendarProperty::Dtstart, ICalendarProperty::Rdate]
.iter()
.any(|p| old.times(oc, p) != new.times(nc, p))
|| old.end(oc) != new.end(nc);
let reinstated = !old
.times(oc, &ICalendarProperty::Exdate)
.is_subset(&new.times(nc, &ICalendarProperty::Exdate));
@@ -1027,7 +1062,7 @@ fn cancelled(comps: Vec<Node>) -> Vec<Node> {
/// For comparing what an attendee would receive: without the stamps a
/// client rewrites on every save, and optionally without participation.
fn normalized(comps: &[Node], without_partstat: bool) -> Vec<Node> {
comps
let mut out = comps
.iter()
.map(|n| {
let mut n = n.clone();
@@ -1054,9 +1089,24 @@ fn normalized(comps: &[Node], without_partstat: bool) -> Vec<Node> {
remove_param(e, &ICalendarParameterName::Partstat);
}
}
sorted(&mut n);
n
})
.collect()
.collect::<Vec<_>>();
out.sort_by_cached_key(|n| format!("{n:?}"));
out
}
/// In one order, so another client's order of properties is no change.
fn sorted(n: &mut Node) {
for e in &mut n.c.entries {
e.params.sort_by_cached_key(|p| format!("{p:?}"));
}
n.c.entries.sort_by_cached_key(|e| format!("{e:?}"));
for ch in &mut n.children {
sorted(ch);
}
n.children.sort_by_cached_key(|ch| format!("{ch:?}"));
}
/// A component of the attendee's REPLY: only its own ATTENDEE, no alarms.
@@ -1282,7 +1332,8 @@ impl Obj {
}
if is_range(&o.c) {
// ponytail: a later range that invites them again is lost too.
if let Some(t) = self.instant(rid) {
// A stale range past the series' end cuts nothing.
if let Some(t) = self.instant(rid).filter(|t| self.in_series(*t)) {
end = Some(end.map_or(t, |e| e.min(t)));
}
continue;
@@ -1322,7 +1373,7 @@ impl Obj {
/// Whether override `c`, read through `obj`, is instance `key` of this
/// series unchanged: its start, length and people, no rules of its own.
fn plain(&self, obj: &Obj, c: &ICalendarComponent, key: i64) -> bool {
let Some(base) = self.future(key).map(|(n, _)| n).or(self.master()) else {
let Some(base) = self.base(key) else {
return false;
};
let start = c
@@ -1336,14 +1387,89 @@ impl Obj {
]
.iter()
.all(|p| !c.has_property(p))
&& obj.length(c) == self.length(&base.c)
&& c.property(&ICalendarProperty::Status).map(|e| &e.values)
== base
.c
.property(&ICalendarProperty::Status)
.map(|e| &e.values)
&& obj.end(c) == self.end_from(&base.c, self.moved(key))
&& organizer_of(c) == organizer_of(&base.c)
&& addresses(c) == addresses(&base.c)
&& (c.status() == base.c.status() || own_progress(c, &base.c))
}
/// Whether the master's own rules have an instance at `key`.
fn in_series(&self, key: i64) -> bool {
let (Some(m), Some(at)) = (self.master(), DateTime::from_timestamp(key, 0)) else {
return false;
};
let mut root = Node {
c: self.root.c.clone(),
children: self
.root
.children
.iter()
.filter(|n| n.c.component_type == ICalendarComponentType::VTimezone)
.cloned()
.collect(),
};
root.children.push(m.clone());
let mut components = Vec::new();
flatten(&root, &mut components);
let window = at - TimeDelta::days(1)..at + TimeDelta::days(1);
expand(&ICalendar { components }, window, Zone::Utc)
.instances
.iter()
.any(|i| i.recurrence_id == Some(at))
}
/// What instance `key` copies: the THISANDFUTURE override that moves it,
/// else the master.
fn base(&self, key: i64) -> Option<&Node> {
self.future(key).map(|(n, _)| n).or(self.master())
}
/// When the component ends: DTSTART plus its length, else its DUE.
fn end(&self, c: &ICalendarComponent) -> Option<i64> {
match c.property(&ICalendarProperty::Dtstart) {
Some(s) => self.end_from(c, self.instant(s)?),
None => self.instant(c.property(&ICalendarProperty::Due)?),
}
}
/// When `c` would end if it started at `start`. The days and weeks of a
/// DURATION count in wall-clock time (RFC 5545, 3.3.6).
fn end_from(&self, c: &ICalendarComponent, start: i64) -> Option<i64> {
let duration = c.property(&ICalendarProperty::Duration);
let d = match duration.and_then(|e| e.values.first()) {
Some(ICalendarValue::Duration(d))
if !d.neg
&& !c.has_property(&ICalendarProperty::Dtend)
&& !c.has_property(&ICalendarProperty::Due) =>
{
d
}
_ => return Some(start + self.length(c)?),
};
let zone = self
.zones
.get(c.property(&ICalendarProperty::Dtstart)?.tz_id());
let days = i64::from(d.weeks) * 7 + i64::from(d.days);
let local = zone.to_local(DateTime::from_timestamp(start, 0)?) + TimeDelta::days(days);
let exact = i64::from(d.hours) * 3600 + i64::from(d.minutes) * 60 + i64::from(d.seconds);
Some(zone.to_utc(local).timestamp() + exact)
}
/// Which of `keys` are instances of the series in `cal`, from one
/// expansion. `None` if the expansion was cut short and cannot tell.
fn instances(&self, cal: &ICalendar, keys: &[i64]) -> Option<HashSet<i64>> {
let starts: Vec<i64> = keys.iter().map(|k| self.moved(*k)).collect();
let at = |t: i64| DateTime::from_timestamp(t, 0);
// A day either side: an all-day shift is whole days, not 24 hours.
let from = at(*starts.iter().min()?)? - TimeDelta::days(1);
let to = at(*starts.iter().max()?)? + TimeDelta::days(1);
let e = expand(cal, from..to, Zone::Utc);
(!e.truncated).then(|| {
e.instances
.iter()
.filter_map(|i| Some(i.recurrence_id?.timestamp()))
.collect()
})
}
/// Seconds from DTSTART to DTEND or DUE, or the DURATION. Without
@@ -1678,6 +1804,15 @@ fn attendees(c: &ICalendarComponent) -> impl Iterator<Item = &ICalendarEntry> {
c.properties(&ICalendarProperty::Attendee)
}
/// A task's progress is the assignee's to set per instance, unless the
/// organizer cancelled it.
fn own_progress(c: &ICalendarComponent, base: &ICalendarComponent) -> bool {
use ICalendarStatus::*;
c.component_type == ICalendarComponentType::VTodo
&& matches!(c.status(), None | Some(NeedsAction | InProcess | Completed))
&& base.status() != Some(&Cancelled)
}
fn organizer_of(c: &ICalendarComponent) -> Option<String> {
c.property(&ICalendarProperty::Organizer)
.and_then(address)
▾Mpimdav/src/object.rs
@@ -9,6 +9,7 @@ use calcard::{Entry, Parser};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::text::{logical_lines, name, unfold, value};
use crate::xml::{CALDAV, CARDDAV, el};
/// Why a PUT body is refused, as the precondition the RFCs name.
@@ -48,7 +49,7 @@ pub struct CalendarObject {
/// component types the collection takes.
pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Invalid> {
let text = std::str::from_utf8(body).map_err(|_| Invalid::CalendarData)?;
if !ends_with(text, "END:VCALENDAR") {
if !ends_with(text, "END:VCALENDAR") || !balanced(text) {
return Err(Invalid::CalendarData);
}
let mut parser = Parser::new(text);
@@ -233,6 +234,25 @@ fn ends_with(text: &str, end: &str) -> bool {
.is_some_and(|l| l.trim().eq_ignore_ascii_case(end))
}
/// Whether every BEGIN has its END. The parser lets END:VCALENDAR close a
/// VEVENT cut off before its own END.
fn balanced(text: &str) -> bool {
let mut open: Vec<String> = Vec::new();
for line in logical_lines(text) {
let n = name(line);
if n != "BEGIN" && n != "END" {
continue;
}
let what = value(&unfold(line)).trim().to_ascii_uppercase();
match n.as_str() {
"BEGIN" => open.push(what),
_ if open.pop().as_ref() != Some(&what) => return false,
_ => {}
}
}
open.is_empty()
}
/// `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.
▾Mpimdav/src/render.rs
@@ -14,7 +14,7 @@ use calcard::vcard::{VCard, VCardVersion};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::expand::{expand, stamp};
use crate::expand::{expand, original, stamp};
use crate::filter::{TimeRange, time_range};
use crate::freebusy::period;
use crate::report::Refused;
@@ -180,6 +180,7 @@ fn expanded(
if exp.truncated || count > MAX_EXPANDED {
return Err(TooManyInstances);
}
let zones = Zones::new(cal, floating.clone());
let mut out = vec![root(cal)];
for x in exp.instances {
let src = &cal.components[x.component];
@@ -202,6 +203,7 @@ fn expanded(
| ICalendarProperty::RecurrenceId
)
});
in_utc(&mut c.entries, &zones);
let when = |name: ICalendarProperty, t: DateTime<Utc>| {
let (value, params) = if date {
let day = floating.to_local(t).and_utc().timestamp();
@@ -231,38 +233,68 @@ fn expanded(
}
out[0].component_ids.push(at);
}
// A task or journal without DTSTART has no instances to expand. Its
// times still go to UTC: the answer holds no VTIMEZONE.
let zones = Zones::new(cal, floating.clone());
// A task or journal without DTSTART has no instances to expand.
for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) {
let c = &cal.components[i as usize];
if c.component_type != ICalendarComponentType::VTimezone
&& !c.has_property(&ICalendarProperty::Dtstart)
{
let at = copy(cal, i as usize, None, &mut out);
for e in &mut out[at as usize].entries {
let tzid = e.tz_id().map(str::to_string);
for v in &mut e.values {
if let ICalendarValue::PartialDateTime(p) = v
&& p.hour.is_some()
&& let Some(s) = stamp(&zones, p, tzid.as_deref())
{
**p = PartialDateTime::from_utc_timestamp(s.utc().timestamp());
}
}
e.params.retain(|p| p.name != ICalendarParameterName::Tzid);
}
in_utc(&mut out[at as usize].entries, &zones);
out[0].component_ids.push(at);
}
}
Ok((ICalendar { components: out }, count))
}
/// Date-times in UTC: an expanded answer holds no VTIMEZONE.
fn in_utc(entries: &mut [ICalendarEntry], zones: &Zones) {
for e in entries {
let tzid = e.tz_id().map(str::to_string);
for v in &mut e.values {
if let ICalendarValue::PartialDateTime(p) = v
&& p.hour.is_some()
&& let Some(s) = stamp(zones, p, tzid.as_deref())
{
**p = PartialDateTime::from_utc_timestamp(s.utc().timestamp());
}
}
e.params.retain(|p| p.name != ICalendarParameterName::Tzid);
}
}
/// The masters, and only the overrides that affect `range` (RFC 4791,
/// 9.6.6).
fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICalendar {
let exp = expand(cal, range.clone(), floating.clone());
let used: HashSet<usize> = exp.instances.iter().map(|x| x.component).collect();
let zones = Zones::new(cal, floating.clone());
// An override moved out of the range still removes an instance in it.
let master = cal.components.iter().position(|c| {
matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
) && !c.has_property(&ICalendarProperty::RecurrenceId)
});
let replaces_in_range = |rid: &ICalendarEntry| {
let Some(s) = rid
.values
.first()
.and_then(|v| v.as_partial_date_time())
.and_then(|p| stamp(&zones, p, rid.tz_id()))
else {
return false;
};
let (start, end) = master
.and_then(|m| original(cal, &zones, m, &s))
.unwrap_or((s.utc(), s.utc()));
match start == end {
true => range.contains(&start),
false => start < range.end && end > range.start,
}
};
let mut out = vec![root(cal)];
for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) {
let c = &cal.components[i as usize];
@@ -271,6 +303,7 @@ fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICal
Some(rid) => {
exp.truncated
|| used.contains(&(i as usize))
|| replaces_in_range(rid)
|| rid.parameter(&ICalendarParameterName::Range).is_some()
}
};
@@ -497,13 +530,14 @@ fn apple_forms(text: &str, version: VCardVersion) -> String {
best.get(&name).copied().unwrap_or(1),
);
let (name, value) = renamed.unwrap_or((&name, value));
let (params, value) = plain_forms(v3, &line, name, params, value);
let mut new = format!("{group}{name}");
for p in &params {
new.push(';');
new.push_str(p);
}
new.push(':');
new.push_str(value);
new.push_str(&value);
if new == line {
out.push_str(raw);
} else {
@@ -513,6 +547,67 @@ fn apple_forms(text: &str, version: VCardVersion) -> String {
out
}
/// vCard 4.0 values vCard 3.0 readers reject, and back: a `tel:` URI as the
/// bare number, and a birthday without year as Apple writes it, with
/// `X-APPLE-OMIT-YEAR`.
fn plain_forms(
v3: bool,
line: &str,
name: &str,
params: Vec<String>,
value: &str,
) -> (Vec<String>, String) {
let key = |p: &str, k: &str| {
p.split_once('=')
.is_some_and(|(pk, _)| pk.trim().eq_ignore_ascii_case(k))
};
let param = |k: &str| crate::text::param(line, k);
let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
match (v3, name) {
(true, "TEL") => {
let uri = param("VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri"));
match value.get(..4).filter(|s| s.eq_ignore_ascii_case("tel:")) {
Some(_) if uri => (
params.into_iter().filter(|p| !key(p, "VALUE")).collect(),
value[4..].to_string(),
),
_ => (params, value.to_string()),
}
}
(true, "BDAY") => {
let md = value.trim().strip_prefix("--").map(|v| v.replace('-', ""));
match md {
Some(md) if md.len() == 4 && digits(&md) => {
let mut params = params;
params.push("X-APPLE-OMIT-YEAR=1604".to_string());
(params, format!("1604-{}-{}", &md[..2], &md[2..]))
}
_ => (params, value.to_string()),
}
}
(false, "BDAY") => {
let year = param("X-APPLE-OMIT-YEAR");
let date = value
.trim()
.split(['T', 't'])
.next()
.unwrap_or_default()
.replace('-', "");
match year {
Some(y) if date.len() == 8 && digits(&date) && date[..4] == y => (
params
.into_iter()
.filter(|p| !key(p, "X-APPLE-OMIT-YEAR"))
.collect(),
format!("--{}", &date[4..]),
),
_ => (params, value.to_string()),
}
}
_ => (params, value.to_string()),
}
}
/// The lowest `PREF` as a `pref` type for vCard 3.0, and back for 4.0.
fn pref_params(params: Vec<&str>, v3: bool, lowest: u32) -> Vec<String> {
let mut out = Vec::with_capacity(params.len());
▾Mpimdav/src/xml.rs
@@ -106,11 +106,24 @@ pub fn update(body: &[u8]) -> Result<Update, Invalid> {
if !expected.iter().any(|(ns, n)| Name::of(&root).is(ns, n)) {
return Err(Invalid);
}
// RFC 4918 applies the instructions in document order, so a later one
// on the same property replaces an earlier one.
for op in elements(&root) {
let props = child(op, DAV, "prop").into_iter().flat_map(elements);
match (op.namespace.as_deref(), op.name.as_str()) {
(Some(DAV), "set") => out.set.extend(props.cloned()),
(Some(DAV), "remove") => out.remove.extend(props.map(Name::of)),
(Some(DAV), "set") => {
for p in props {
let name = Name::of(p);
out.remove.retain(|n| *n != name);
out.set.push(p.clone());
}
}
(Some(DAV), "remove") => {
for name in props.map(Name::of) {
out.set.retain(|p| Name::of(p) != name);
out.remove.push(name);
}
}
_ => {}
}
}
▾Mpimdav/src/zone.rs
@@ -1,7 +1,7 @@
//! Time zones: resolving TZIDs, evaluating VTIMEZONE rules, and converting
//! wall-clock time to UTC.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
@@ -104,18 +104,28 @@ pub struct Zones {
impl Zones {
/// `floating` interprets values without a zone, dates included.
pub fn new(cal: &ICalendar, floating: Zone) -> Self {
let by_tzid = cal
// Only the zones the object uses: each VTIMEZONE costs its rules.
let used: HashSet<&str> = cal
.components
.iter()
.filter(|c| c.component_type == ICalendarComponentType::VTimezone)
.filter_map(|c| {
let id = text_prop(c, &ICalendarProperty::Tzid)?;
Some((id.to_string(), vtimezone(cal, c, id)?))
});
.flat_map(|c| c.entries.iter().filter_map(|e| e.tz_id()))
.collect();
let mut map = HashMap::new();
// A repeated TZID is invalid. The first definition wins.
for (id, zone) in by_tzid {
map.entry(id).or_insert(zone);
for c in cal
.components
.iter()
.filter(|c| c.component_type == ICalendarComponentType::VTimezone)
{
let Some(id) = text_prop(c, &ICalendarProperty::Tzid).filter(|id| used.contains(id))
else {
continue;
};
// A repeated TZID is invalid. The first definition wins.
if !map.contains_key(id)
&& let Some(zone) = vtimezone(cal, c, id)
{
map.insert(id.to_string(), zone);
}
}
Zones {
by_tzid: map,
▾Mpimdav/tests/bundle.rs
@@ -311,6 +311,20 @@ fn an_override_follows_the_master_that_has_its_instance() {
}
}
#[test]
fn override_placement_reads_the_masters_own_time_zones() {
let file = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VTIMEZONE\r\nTZID:My Zone\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n\
TZOFFSETFROM:-1000\r\nTZOFFSETTO:-1000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART;TZID=My Zone:20240101T080000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nDTSTART:20240101T120000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:B\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240102T180000Z\r\nDTSTART:20240102T200000Z\r\nSUMMARY:A1\r\nEND:VEVENT\r\n\
END:VCALENDAR\r\n";
let parts = bundle::split_calendar(file, &mut uids(), usize::MAX).unwrap();
let a = parts.iter().find(|p| p.contains("SUMMARY:A\r\n")).unwrap();
assert!(a.contains("SUMMARY:A1"), "{parts:?}");
}
#[test]
fn a_free_this_and_future_override_frees_the_later_instances() {
let object = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
@@ -325,3 +339,58 @@ fn a_free_this_and_future_override_frees_the_later_instances() {
let busy = pimdav::freebusy::busy(&cal, &range, &pimdav::zone::Zone::Utc, None);
assert_eq!(busy.len(), 2, "{busy:?}");
}
#[test]
fn feeds_skip_a_component_cut_off_before_its_end() {
let cut = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:cut\r\nSUMMARY:x\r\nEND:VCALENDAR\r\n";
let fine = object("BEGIN:VEVENT\r\nUID:fine\r\nDTSTART:20240101T100000Z\r\nEND:VEVENT\r\n");
let feed = bundle::calendar(&[cut, &fine], None, Detail::All);
assert!(!feed.contains("UID:cut"), "{feed}");
assert!(feed.contains("UID:fine"), "{feed}");
}
#[test]
fn overrides_of_several_masters_are_placed_quickly() {
let mut file = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n");
for (summary, start) in [("A", "19700101T000000Z"), ("B", "19700101T000001Z")] {
file.push_str(&format!(
"BEGIN:VEVENT\r\nUID:m\r\nDTSTART:{start}\r\nRRULE:FREQ=SECONDLY;INTERVAL=2\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\n"
));
}
for n in 0..20 {
file.push_str(&format!(
"BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:20240101T0000{:02}Z\r\nDTSTART:20240102T000000Z\r\nEND:VEVENT\r\n",
n * 2 + 1
));
}
file.push_str("END:VCALENDAR\r\n");
let started = std::time::Instant::now();
let parts = bundle::split_calendar(&file, &mut uids(), usize::MAX).unwrap();
assert!(
started.elapsed().as_secs_f64() < 2.0,
"{:?}",
started.elapsed()
);
// B started last before them.
assert_eq!(parts.len(), 2);
assert_eq!(
parts[1].matches("RECURRENCE-ID").count(),
20,
"{}",
parts[1]
);
}
#[test]
fn a_busy_range_override_of_a_free_series_stays_busy() {
let object = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:s\r\nDTSTART:20240101T100000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:s\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\n\
DTSTART:20240103T100000Z\r\nDURATION:PT1H\r\nTRANSP:OPAQUE\r\nEND:VEVENT\r\n\
END:VCALENDAR\r\n";
let feed = bundle::calendar(&[object], None, Detail::Busy);
let cal = pimdav::calcard::icalendar::ICalendar::parse(&feed).unwrap();
let range = "2024-01-01T00:00:00Z".parse().unwrap().."2024-01-10T00:00:00Z".parse().unwrap();
let busy = pimdav::freebusy::busy(&cal, &range, &pimdav::zone::Zone::Utc, None);
assert_eq!(busy.len(), 3, "{busy:?}\n{feed}");
}
▾Mpimdav/tests/contact.rs
@@ -119,3 +119,20 @@ fn non_ascii_dates_are_ignored() {
};
assert!(pimdav::render::address_data(&odd, &v4).contains("BEGIN:VCARD"));
}
#[test]
fn many_apple_dates_stay_linear() {
let mut lines = String::new();
for n in 0..20_000 {
lines.push_str(&format!("g{n}.X-ABDATE:2000-01-01\n"));
}
lines.push_str("g7.X-ABLABEL:_$!<Anniversary>!$_\n");
let started = std::time::Instant::now();
let dates = contact::dates(&card(&lines), "k");
assert!(
started.elapsed().as_secs_f64() < 2.0,
"{:?}",
started.elapsed()
);
assert_eq!(dates.len(), 1);
}
▾Mpimdav/tests/expand.rs
@@ -446,3 +446,47 @@ fn the_rules_of_one_series_share_one_budget() {
let days = utc("2026-06-01T00:00")..utc("2026-06-11T00:00");
assert!(expand(&cal, days, Zone::Utc).truncated);
}
#[test]
fn bysetpos_gives_the_same_instances_in_any_window() {
// The second of Mon, Wed, Fri in each week is the Wednesday.
let body = event(
"a",
"DTSTART:20240103T100000Z\r\nDTEND:20240103T110000Z\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;BYSETPOS=2\r\n",
);
let all = instances(&body, "2024-01-01T00:00", "2024-04-01T00:00");
for day in 1..=28 {
for hour in [0, 9, 10, 12] {
let lo = format!("2024-02-{day:02}T{hour:02}:00");
let hi = format!("2024-03-{day:02}T00:00");
let part = instances(&body, &lo, &hi);
let want: Vec<_> = all
.iter()
.filter(|i| i.split('/').nth(1).unwrap() > lo.as_str() && i.as_str() < hi.as_str())
.cloned()
.collect();
assert_eq!(part, want, "{lo}");
}
}
}
#[test]
fn unused_time_zones_cost_nothing() {
let zone = "BEGIN:VTIMEZONE\r\nTZID:Never/{n}\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\n\
TZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nRRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30\r\n\
END:STANDARD\r\nEND:VTIMEZONE\r\n";
let zones: String = (0..300)
.map(|n| zone.replace("{n}", &n.to_string()))
.collect();
let body = zones
+ &event(
"a",
"DTSTART:20240101T100000Z\r\nDTEND:20240101T110000Z\r\n",
);
let t = std::time::Instant::now();
assert_eq!(
instances(&body, "2024-01-01T00:00", "2024-01-02T00:00"),
["2024-01-01T10:00/2024-01-01T11:00"]
);
assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed());
}
▾Mpimdav/tests/itip.rs
@@ -1079,3 +1079,237 @@ fn a_range_from_the_first_instance_cancels_the_whole_series() {
let bob = to(&msgs, BOB).unwrap();
assert_eq!(bob.method, Method::Cancel, "{}", text(&bob.cal));
}
#[test]
fn an_attendee_may_write_the_same_end_in_another_form() {
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
for (stored, written) in [
(
"DTSTART:20260105T100000Z\nDURATION:PT1H",
"DTSTART:20260105T100000Z\nDTEND:20260105T110000Z",
),
(
"DTSTART;VALUE=DATE:20260105",
"DTSTART;VALUE=DATE:20260105\nDTEND;VALUE=DATE:20260106",
),
(
"DTSTART:20260105T100000Z",
"DTSTART:20260105T100000Z\nDTEND:20260105T100000Z",
),
// A nominal day across the October change lasts 25 hours.
(
"DTSTART;TZID=Europe/Berlin:20261024T120000\nDURATION:P1D",
"DTSTART;TZID=Europe/Berlin:20261024T120000\nDTEND;TZID=Europe/Berlin:20261025T120000",
),
] {
let event = |times: &str| {
cal(&format!(
"BEGIN:VEVENT\nUID:m1\n{times}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
))
};
let accepted = text(&event(written)).replace(
&format!("ATTENDEE:{BOB}"),
&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
);
let (_, reply) = itip::attend(
&event(stored),
ICalendar::parse(&accepted).unwrap(),
&is(BOB),
now(),
)
.unwrap_or_else(|e| panic!("{stored}: {e:?}"));
assert!(reply.is_some(), "{stored}");
}
}
#[test]
fn a_new_override_that_changes_the_length_is_a_reschedule() {
let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let new = cal(&format!(
"{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
text(&old)
.trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
.trim_end_matches("END:VCALENDAR\r\n")
.replace("\r\n", "\n")
));
let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
let store = text(&store.unwrap());
let longer = store.split("RECURRENCE-ID").nth(1).unwrap();
assert!(longer.contains("SEQUENCE:1"), "{store}");
assert!(
longer.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
"{store}"
);
}
#[test]
fn an_attendee_may_complete_one_instance() {
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
let series = format!(
"BEGIN:VTODO\nUID:t1\nDTSTART:20260105T100000Z\nDUE:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VTODO\n"
);
let done = cal(&format!(
"{series}BEGIN:VTODO\nUID:t1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
DUE:20260112T110000Z\nSTATUS:COMPLETED\n{people}END:VTODO\n"
));
assert!(itip::attend(&cal(&series), done, &is(BOB), now()).is_ok());
}
#[test]
fn attendees_keep_the_organizers_status_of_an_instance() {
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
let master = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=4\nSTATUS:CONFIRMED\n{people}END:VEVENT\n"
);
let instance = |status: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
DTEND:20260112T110000Z\nSTATUS:{status}\n{people}END:VEVENT\n"
)
};
let tentative = cal(&format!("{master}{}", instance("TENTATIVE")));
assert_eq!(
itip::attend(&tentative, cal(&master), &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
let added = cal(&format!("{master}{}", instance("TENTATIVE")));
assert_eq!(
itip::attend(&cal(&master), added, &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
let same = cal(&format!("{master}{}", instance("CONFIRMED")));
assert!(itip::attend(&cal(&master), same, &is(BOB), now()).is_ok());
}
#[test]
fn dropping_a_declined_instance_replies_with_the_series_answer() {
let master = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
);
let copy = cal(&format!(
"{master}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
DTEND:20260112T110000Z\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
ATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
));
let (_, reply) = itip::attend(&copy, cal(&master), &is(BOB), now()).unwrap();
let reply = text(&reply.unwrap().cal);
assert!(reply.contains("RECURRENCE-ID:20260112T100000Z"), "{reply}");
assert!(
reply.contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
"{reply}"
);
}
#[test]
fn a_reordered_save_sends_nothing() {
let old = meeting(&format!(
"ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nLOCATION:Room 1\n"
));
let (store, _) = itip::organize(None, Some(old), &is(ALICE), now());
let old = store.unwrap();
let reordered = text(&old)
.replace("SUMMARY:Sync\r\n", "")
.replace("LOCATION:Room 1\r\n", "LOCATION:Room 1\r\nSUMMARY:Sync\r\n")
.replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260102T000000Z");
let (_, msgs) = itip::organize(
Some(&old),
Some(ICalendar::parse(&reordered).unwrap()),
&is(ALICE),
now(),
);
assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
}
#[test]
fn an_override_of_no_instance_is_refused_and_many_overrides_stay_fast() {
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
let series = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=DAILY;COUNT=5000\n{people}END:VEVENT\n"
);
let over = |rid: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\n\
DTEND:{}\n{people}END:VEVENT\n",
rid.replace("T10", "T11")
)
};
let stray = cal(&format!("{series}{}", over("20260112T103000Z")));
assert_eq!(
itip::attend(&cal(&series), stray, &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
let day = |i: i64| {
(chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
.format("%Y%m%dT100000Z")
.to_string()
};
let many: String = (0..3000)
.map(|i| {
over(&day(i)).replace(
&format!("ATTENDEE:{BOB}"),
&format!("ATTENDEE;PARTSTAT=DECLINED:{BOB}"),
)
})
.collect();
let t = std::time::Instant::now();
let (_, reply) = itip::attend(
&cal(&series),
cal(&format!("{series}{many}")),
&is(BOB),
now(),
)
.unwrap();
let mut org = cal(&series);
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
}
#[test]
fn far_apart_replies_on_a_dense_series_all_land() {
let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
let series = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1M\n\
RRULE:FREQ=MINUTELY\n{people}END:VEVENT\n"
);
let declined = |rid: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1M\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
)
};
let copy = format!(
"{series}{}{}",
declined("20260105T100000Z"),
declined("20280105T100000Z")
);
let (_, reply) = itip::attend(&cal(&series), cal(&copy), &is(BOB), now()).unwrap();
let mut org = cal(&series);
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
assert_eq!(
text(&org).matches("PARTSTAT=DECLINED").count(),
2,
"{}",
text(&org)
);
}
#[test]
fn a_stale_range_past_the_series_cuts_nothing() {
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=DAILY;COUNT=3\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260115T100000Z\n\
DTSTART:20260115T120000Z\nDTEND:20260115T130000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let bob = text(&to(&msgs, BOB).unwrap().cal);
assert!(bob.contains("COUNT=3"), "{bob}");
assert!(!bob.contains("UNTIL"), "{bob}");
}
▾Mpimdav/tests/protocol.rs
@@ -1,7 +1,7 @@
//! XML request parsing, response building, and PUT body validation.
use pimdav::object::{self, CalendarObject, Invalid};
use pimdav::xml::{self, CALDAV, DAV, Name, Propfind};
use pimdav::xml::{self, APPLE, CALDAV, DAV, Name, Propfind};
use xmltree::Element;
#[test]
@@ -35,9 +35,10 @@ fn update_bodies() {
<d:set><d:prop><d:displayname>Work</d:displayname><i:calendar-color>#ff0000</i:calendar-color></d:prop></d:set>
<d:remove><d:prop><d:displayname/></d:prop></d:remove>
</d:propertyupdate>"#;
// The later remove replaces the set of the same property.
let u = xml::update(body).unwrap();
assert_eq!(u.set.len(), 2);
assert_eq!(xml::text(&u.set[0]), "Work");
assert_eq!(u.set.len(), 1);
assert!(Name::of(&u.set[0]).is(APPLE, "calendar-color"));
assert_eq!(u.remove, vec![Name::new(DAV, "displayname")]);
let body = br#"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
@@ -292,6 +293,12 @@ fn a_cut_or_nested_object_is_refused() {
object::calendar(cut, &["VEVENT"]),
Err(Invalid::CalendarData)
);
// END:VCALENDAR would close the open VEVENT for the parser.
let open = EVENT.replace("END:VEVENT\r\n", "");
assert_eq!(
object::calendar(&ics(&open), &["VEVENT"]),
Err(Invalid::CalendarData)
);
let inner = EVENT.replace("UID:a", "UID:b");
let nested = EVENT.replace("END:VEVENT", &format!("{inner}END:VEVENT"));
assert_eq!(
▾Mpimdav/tests/report.rs
@@ -553,3 +553,69 @@ fn expand_puts_a_kept_task_in_utc() {
assert!(out.contains("DUE:20260105T090000Z"), "{out}");
assert!(!out.contains("TZID"), "{out}");
}
#[test]
fn limit_recurrence_keeps_an_override_moved_out_of_the_range() {
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID:20260102T100000Z\r\nDTSTART:20260301T100000Z\r\nEND:VEVENT\r\n\
END:VCALENDAR\r\n";
let req = pimdav::render::CalendarData {
limit_recurrence: Some(utc("2026-01-01T00:00:00")..utc("2026-01-05T00:00:00")),
..Default::default()
};
let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
assert!(out.contains("RECURRENCE-ID:20260102T100000Z"), "{out}");
}
#[test]
fn limit_recurrence_keeps_an_override_whose_original_overlaps_the_range() {
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T093000Z\r\nDTEND:20260101T103000Z\r\n\
RRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID:20260102T093000Z\r\n\
DTSTART:20260301T093000Z\r\nDTEND:20260301T103000Z\r\nEND:VEVENT\r\n\
END:VCALENDAR\r\n";
let req = pimdav::render::CalendarData {
limit_recurrence: Some(utc("2026-01-02T10:00:00")..utc("2026-01-02T11:00:00")),
..Default::default()
};
let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
assert!(out.contains("RECURRENCE-ID:20260102T093000Z"), "{out}");
}
#[test]
fn expand_puts_every_zoned_time_of_an_instance_in_utc() {
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\
DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\
END:STANDARD\r\nEND:VTIMEZONE\r\n\
BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260105T100000\r\n\
CREATED;TZID=Europe/Berlin:20251201T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let req = pimdav::render::CalendarData {
expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")),
..Default::default()
};
let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
assert!(out.contains("CREATED:20251201T090000Z"), "{out}");
assert!(!out.contains("TZID"), "{out}");
}
#[test]
fn vcard_three_gets_plain_numbers_and_apple_birthdays() {
let v4 = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:A\r\n\
TEL;VALUE=uri;TYPE=cell:tel:+49-1\r\nBDAY:--0315\r\nEND:VCARD\r\n";
let to = |v| AddressData {
props: None,
version: Some(v),
};
use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
let v3 = address_data(v4, &to(V3_0));
assert!(v3.contains("TEL;TYPE=CELL:+49-1\r\n"), "{v3}");
assert!(
v3.contains("BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\r\n"),
"{v3}"
);
let back = address_data(&v3, &to(V4_0));
assert!(back.contains("BDAY:--0315\r\n"), "{back}");
}
▾Mserver/src/api/admin.rs
@@ -96,21 +96,30 @@ pub async fn create_user(
let name = body.name.trim().to_string();
validate_account_name(&name)?;
validate_password(&body.password)?;
// Rooms and resources share the name space.
if state.db.name_taken(&name).await? {
return Err(ApiError::localized(
let taken = || {
ApiError::localized(
StatusCode::CONFLICT,
"a user with that name already exists",
"err_user_exists",
));
)
};
// Rooms and resources share the name space.
if state.db.name_taken(&name).await? {
return Err(taken());
}
let roots = validate_roots(&state, &body.roots).await?;
let pass_hash = hash_password(&body.password).await?;
let user = state
let user = match state
.db
.create_user(&name, &pass_hash, body.is_admin, &roots)
.await?;
.await
{
Ok(u) => u,
// A user or room of that name may have come in since the check.
Err(_) if state.db.name_taken(&name).await? => return Err(taken()),
Err(e) => return Err(e.into()),
};
let roots = state.db.user_roots(user.id).await?;
Ok(Json(admin_user(&state, &user, &roots)))
}
▾Mserver/src/api/pim.rs
@@ -23,7 +23,7 @@ use api_types::PIM;
use axum::body::Body;
use axum::extract::State;
use axum::http::header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LOCATION};
use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
use axum::http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode};
use axum::response::IntoResponse;
use percent_encoding::{
AsciiSet, CONTROLS, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode,
@@ -72,9 +72,11 @@ pub(super) fn valid_text(v: &str, max: usize, lines: bool) -> bool {
const MAX_XML_SIZE: usize = 1024 * 1024;
/// Largest client property the server stores without interpreting it, and
/// the most one resource may hold.
/// the most one resource may hold. The total, `calendar-timezone` included,
/// bounds what a PROPFIND of a home returns.
const MAX_DEAD_SIZE: usize = 64 * 1024;
const MAX_DEAD_PROPS: usize = 100;
const MAX_DEAD_TOTAL: usize = 256 * 1024;
/// The domain of the addresses users schedule with. `.invalid` is reserved
/// (RFC 2606), so nothing sent there can reach anyone.
@@ -270,7 +272,7 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
me: &me,
space: space.as_ref(),
};
match method.as_str() {
let reply = match method.as_str() {
"OPTIONS" => Ok(options(&target)),
"POST" => cx.post(&target, body).await,
"PROPFIND" => cx.propfind(&target, &parts.headers, body).await,
@@ -285,6 +287,24 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
"REPORT" => cx.report(&target, body).await,
"MOVE" => cx.move_object(&target, &parts.headers).await,
_ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
};
reply.map(|mut r| {
if r.status() == StatusCode::METHOD_NOT_ALLOWED {
r.headers_mut()
.insert(ALLOW, HeaderValue::from_static(allowed(&target)));
}
r
})
}
/// The methods a 405 names in `Allow`. OPTIONS keeps its wider list, which
/// clients read for what a URL may become.
fn allowed(target: &Target) -> &'static str {
match target {
Target::Collection(PimKind::Calendar, _, s) if s == OUTBOX => "OPTIONS, PROPFIND, POST",
Target::Collection(..) => "OPTIONS, GET, HEAD, DELETE, PROPFIND, PROPPATCH, REPORT",
Target::Object(..) => "OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, REPORT",
_ => "OPTIONS, PROPFIND, PROPPATCH, REPORT",
}
}
@@ -1755,28 +1775,25 @@ fn apply(
|n: &Name| live.contains(n) || (n.ns == DAV && PROTECTED.contains(&n.local.as_str()));
for p in &update.set {
let name = Name::of(p);
let xml = xml::document(p);
let own = col
.as_mut()
.and_then(|(kind, c)| set_own(*kind, c, p, &name, creating));
let code = match own {
Some(true) => 200,
Some(false) => 403,
None if is_protected(&name) => {
patch.protected = true;
403
}
_ if xml.len() > MAX_DEAD_SIZE => 507,
Some(true) => 200,
None => {
let xml = xml::document(p);
if xml.len() > MAX_DEAD_SIZE {
507
} else {
patch.set.push(DeadProp {
ns: name.ns.clone(),
name: name.local.clone(),
xml,
});
200
}
patch.set.push(DeadProp {
ns: name.ns.clone(),
name: name.local.clone(),
xml,
});
200
}
};
patch.results.push((code, name.element()));
@@ -1811,10 +1828,33 @@ fn apply(
.collect();
names.sort_unstable();
names.dedup();
if names.len() > MAX_DEAD_PROPS {
for (code, prop) in &mut patch.results {
let replaced = |p: &DeadProp| {
patch
.set
.iter()
.any(|s| (&s.ns, &s.name) == (&p.ns, &p.name))
|| patch
.remove
.iter()
.any(|(ns, l)| (ns, l) == (&p.ns, &p.name))
};
let size = stored
.iter()
.filter(|p| !replaced(p))
.chain(&patch.set)
.map(|p| p.xml.len())
.sum::<usize>()
+ col
.as_ref()
.and_then(|(_, c)| c.timezone.as_ref())
.map_or(0, String::len);
if names.len() > MAX_DEAD_PROPS || size > MAX_DEAD_TOTAL {
// Only what adds to the total is refused.
for (code, prop) in patch.results.iter_mut().take(update.set.len()) {
let n = Name::of(prop);
if patch.set.iter().any(|p| p.ns == n.ns && p.name == n.local) {
if n.is(CALDAV, "calendar-timezone")
|| patch.set.iter().any(|p| p.ns == n.ns && p.name == n.local)
{
*code = 507;
}
}
@@ -2080,6 +2120,9 @@ impl Cx<'_> {
}
let db = &self.state.db;
let current = self.member(&col, name).await?;
if current.is_none() && name.len() > MAX_SLUG {
return Ok(status(StatusCode::FORBIDDEN));
}
if refuses(headers, current.as_ref().map(|(o, _)| o)) {
return Ok(status(StatusCode::PRECONDITION_FAILED));
}
@@ -2813,13 +2856,12 @@ impl Cx<'_> {
));
}
let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
let target = self.member(&to.c, &to_name).await?;
if target.is_none() && to_name.len() > MAX_SLUG {
return Ok(status(StatusCode::FORBIDDEN));
}
// Overwriting a meeting would drop it without telling its attendees.
if overwrite
&& self
.member(&to.c, &to_name)
.await?
.is_some_and(|(o, _)| o.schedule_tag.is_some())
{
if overwrite && target.is_some_and(|(o, _)| o.schedule_tag.is_some()) {
return Ok(status(StatusCode::FORBIDDEN));
}
let written = self
▾Mserver/src/api/pim_api.rs
@@ -53,6 +53,8 @@ use crate::error::{ApiError, AppState};
/// The largest file an import reads.
const MAX_IMPORT: usize = 20 * 1024 * 1024;
const MAX_LINKS: usize = 50;
/// Largest total an import may split into. Each object carries a copy of
/// the time zones it names.
const MAX_SPLIT: usize = 128 * 1024 * 1024;
@@ -357,12 +359,35 @@ pub async fn update(
}
col.transparent = t;
}
// As schedule-default-calendar-URL over DAV: an own calendar that takes
// events. own() already rules out the inbox and generated ones.
let takes_events = col.components.split(',').any(|x| x == "VEVENT");
if body.is_default == Some(true) && (kind != PimKind::Calendar || !takes_events) {
return Err(bad_request(
"only a calendar that takes events receives invitations",
));
}
state
.db
.pim_patch(PropPlace::Collection(id), Some((&before, &col)), &[], &[])
.await?;
let pid = state.db.principal_of(auth.user.id).await?;
if body.is_default == Some(true) {
let _lock = pim_schedule::LOCK.lock().await;
state.db.pim_set_default_calendar(pid, Some(id)).await?;
}
let is_default = kind == PimKind::Calendar
&& state
.db
.pim_calendar_for(pid, "VEVENT")
.await?
.map(|c| c.id)
== Some(id);
let url = collection_href(&auth.user.name, kind, &col.slug, None);
Ok(Json(info(&col, kind, url, &auth.user.name, None)))
Ok(Json(PimCollectionInfo {
is_default,
..info(&col, kind, url, &auth.user.name, None)
}))
}
/// DELETE {PIM_COLLECTIONS}/{id}: an own collection, or the loan of a lent
@@ -665,13 +690,25 @@ pub async fn create_link(
));
}
// As for shares: an unparseable expiry would never expire.
if let Some(e) = &body.expires_at
&& chrono::DateTime::parse_from_rfc3339(e).is_err()
{
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"expires_at must be an RFC 3339 timestamp",
"err_bad_expires_at",
if let Some(e) = &body.expires_at {
match chrono::DateTime::parse_from_rfc3339(e) {
Err(_) => {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"expires_at must be an RFC 3339 timestamp",
"err_bad_expires_at",
));
}
Ok(t) if t <= chrono::Utc::now() => {
return Err(bad_request("expires_at is in the past"));
}
Ok(_) => {}
}
}
if state.db.pim_links(id).await?.len() >= MAX_LINKS {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
format!("a collection has at most {MAX_LINKS} feeds"),
));
}
let password_hash = match body.password.as_deref().map(str::trim) {
@@ -869,7 +906,7 @@ pub async fn import(
return Err(ApiError::new(StatusCode::FORBIDDEN, "read-only collection"));
}
let text = read_import(body).await?;
let parts = split_import(kind, &text)?;
let parts = blocking(move || split_import(kind, &text)).await?;
Ok(Json(import_parts(&state, &auth, kind, &col, parts).await?))
}
@@ -892,11 +929,14 @@ pub async fn import_new(
) -> Result<Json<PimImportNew>, ApiError> {
let kind = db_kind(q.kind);
let text = read_import(body).await?;
let parts = split_import(kind, &text)?;
let (own_name, own_color) = match kind {
PimKind::Calendar => bundle::calendar_meta(&text),
PimKind::AddressBook => (None, None),
};
let (parts, (own_name, own_color)) = blocking(move || -> Result<_, ApiError> {
let meta = match kind {
PimKind::Calendar => bundle::calendar_meta(&text),
PimKind::AddressBook => (None, None),
};
Ok((split_import(kind, &text)?, meta))
})
.await?;
let nonempty = |s: Option<String>| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
// A name from the file that cannot be stored falls back to the next one.
let usable = |s: Option<String>| nonempty(s).filter(|s| valid_text(s, MAX_DISPLAYNAME, false));
▾Mserver/src/api/pim_views.rs
@@ -261,11 +261,14 @@ pub async fn object(
is_owner: a.is_owner,
})
.collect(),
series_partstat: view::component_for(&cal, None, &zone).and_then(|m| {
view::event_info(&cal, m, &owns)
.partstat()
.map(str::to_string)
}),
is_override: cal.components[index].has_property(&ICalendarProperty::RecurrenceId),
series_partstat: view::component_for(&cal, None, &zone)
.filter(|&m| !cal.components[m].has_property(&ICalendarProperty::RecurrenceId))
.and_then(|m| {
view::event_info(&cal, m, &owns)
.partstat()
.map(str::to_string)
}),
can_edit: writable,
can_reply: attendee && answers,
})))
▾Mserver/tests/api_pim.rs
@@ -2080,3 +2080,143 @@ async fn deleting_a_missing_object_records_no_change() {
.is_empty()
);
}
#[tokio::test]
async fn dead_properties_are_capped_in_total() {
let (env, auth) = setup().await;
let big = "x".repeat(60 * 1024);
let patch = |names: &[&str]| {
let props: String = names
.iter()
.map(|n| format!("<x:{n}>{big}</x:{n}>"))
.collect();
format!(
"<d:propertyupdate xmlns:d=\"DAV:\" xmlns:x=\"urn:x\"><d:set><d:prop>{props}</d:prop></d:set></d:propertyupdate>"
)
};
let codes =
|r: &Resp| -> Vec<u16> { parse_multistatus(r)[0].1.iter().map(|(c, _)| *c).collect() };
let r = req(
&env,
"PROPPATCH",
CAL,
&auth,
&[],
&patch(&["a", "b", "c", "d"]),
)
.await;
assert_eq!(codes(&r), [200; 4], "{}", r.text());
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &patch(&["e"])).await;
assert_eq!(codes(&r), [507], "{}", r.text());
// Replacing one keeps the total.
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &patch(&["a"])).await;
assert_eq!(codes(&r), [200], "{}", r.text());
let tz = format!(
"<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
<c:calendar-timezone>{}</c:calendar-timezone></d:prop></d:set></d:propertyupdate>",
"y".repeat(70 * 1024)
);
// Checked as a time zone first, then for size.
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &tz).await;
assert_eq!(codes(&r), [403], "{}", r.text());
let tz = format!(
"<d:propertyupdate xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\"><d:set><d:prop>\
<c:calendar-timezone>BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nBEGIN:VTIMEZONE\r\n\
TZID:Europe/Berlin\r\nX-PAD:{}\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\n\
TZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\
END:VCALENDAR\r\n</c:calendar-timezone></d:prop></d:set></d:propertyupdate>",
"y".repeat(70 * 1024)
);
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &tz).await;
assert_eq!(codes(&r), [507], "{}", r.text());
// A protected property stays protected, whatever its size.
let etag = format!(
"<d:propertyupdate xmlns:d=\"DAV:\"><d:set><d:prop><d:getetag>{}</d:getetag>\
</d:prop></d:set></d:propertyupdate>",
"z".repeat(70 * 1024)
);
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &etag).await;
assert_eq!(codes(&r), [403], "{}", r.text());
}
#[tokio::test]
async fn proppatch_follows_document_order() {
let (env, auth) = setup().await;
let body = |first: &str, second: &str| {
let op = |o: &str| format!("<d:{o}><d:prop><x:note>v</x:note></d:prop></d:{o}>");
format!(
"<d:propertyupdate xmlns:d=\"DAV:\" xmlns:x=\"urn:x\">{}{}</d:propertyupdate>",
op(first),
op(second)
)
};
let props =
"<d:propfind xmlns:d=\"DAV:\" xmlns:x=\"urn:x\"><d:prop><x:note/></d:prop></d:propfind>";
for (first, second, kept) in [("set", "remove", false), ("remove", "set", true)] {
let r = req(&env, "PROPPATCH", CAL, &auth, &[], &body(first, second)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
let r = req(&env, "PROPFIND", CAL, &auth, &[("depth", "0")], props).await;
let ms = parse_multistatus(&r);
assert_eq!(
prop(&ms, CAL, "urn:x", "note").is_some(),
kept,
"{first} then {second}"
);
}
}
#[tokio::test]
async fn long_names_and_405s() {
let (env, auth) = setup().await;
let long = format!("{CAL}{}.ics", "n".repeat(300));
let r = req(&env, "PUT", &long, &auth, &[], &ics(LUNCH)).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let short = format!("{CAL}short.ics");
let r = req(&env, "PUT", &short, &auth, &[], &ics(LUNCH)).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(&env, "MOVE", &short, &auth, &[("destination", &long)], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// An object stored under a long name before the limit can still be updated.
let db = &env.state.db;
let pid = db.pim_principal("alice").await.unwrap().unwrap().id;
let col = db
.pim_collection(pid, server::db::PimKind::Calendar, "default")
.await
.unwrap()
.unwrap();
let (mut obj, data) = db.pim_object(col.id, "short.ics").await.unwrap().unwrap();
obj.name = long.rsplit('/').next().unwrap().to_string();
db.pim_apply(&[
server::db::PimOp::Delete {
collection_id: col.id,
name: "short.ics".into(),
},
server::db::PimOp::Put {
collection_id: col.id,
obj,
data,
},
])
.await
.unwrap();
let r = req(
&env,
"PUT",
&long,
&auth,
&[],
&ics(&LUNCH.replace("Team", "Long")),
)
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
let r = req(&env, "PUT", CAL, &auth, &[], &ics(LUNCH)).await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
let allow = r.header("allow").unwrap();
assert!(
allow.contains("PROPFIND") && !allow.contains("PUT"),
"{allow}"
);
}
▾Mserver/tests/api_pim_io.rs
@@ -184,9 +184,10 @@ async fn feeds() {
StatusCode::OK
);
let expired = io
.link(cal, json!({ "expires_at": "2000-01-01T00:00:00Z" }))
.await;
// A link cannot be made expired, so this one runs out.
let soon = (chrono::Utc::now() + chrono::Duration::seconds(1)).to_rfc3339();
let expired = io.link(cal, json!({ "expires_at": soon })).await;
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
assert_eq!(io.anon(&expired, &[]).await.status, StatusCode::GONE);
let cards = io.link(book, json!({})).await;
▾Mserver/tests/api_pim_ui.rs
@@ -266,6 +266,16 @@ async fn instances_expand_readable_calendars() {
assert_eq!(list[3]["recurrence_id"], "2026-01-12T08:00:00Z");
assert_eq!(list[1]["collection_id"], -1);
assert_eq!(all["truncated"], false);
let detail = |rid: &str| {
let alice = ui.alice.clone();
let url = format!(
"/api/pim/collections/{}/objects/weekly.ics?recurrence_id={rid}",
list[0]["collection_id"]
);
async move { alice.get(&url).await.json() }
};
assert_eq!(detail("2026-01-12T08:00:00Z").await["is_override"], true);
assert_eq!(detail("2026-01-05T08:00:00Z").await["is_override"], false);
// Only the chosen calendars.
let r = get("from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&collections=-1").await;
@@ -605,3 +615,94 @@ async fn tasks_are_not_instances() {
.await;
assert!(r.json()["instances"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn the_default_calendar_can_be_chosen() {
let ui = Ui::new().await;
let old = ui
.list(&ui.alice)
.await
.into_iter()
.find(|c| c["is_default"] == true)
.unwrap()["id"]
.as_i64()
.unwrap();
let mut ids = Vec::new();
for body in [
json!({"kind": "calendar", "name": "Work"}),
json!({"kind": "calendar", "name": "Todo", "components": ["VTODO"]}),
json!({"kind": "addressbook", "name": "People"}),
] {
let r = ui.alice.post_json("/api/pim/collections", &body).await;
ids.push(r.json()["id"].as_i64().unwrap());
}
let [work, tasks, book] = ids[..] else {
unreachable!()
};
let choose = json!({"is_default": true});
for refused in [tasks, book] {
let r = ui
.alice
.put_json(&format!("/api/pim/collections/{refused}"), &choose)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
let r = ui
.alice
.put_json(&format!("/api/pim/collections/{work}"), &choose)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["is_default"], true);
let list = ui.list(&ui.alice).await;
let default: Vec<i64> = list
.iter()
.filter(|c| c["is_default"] == true)
.map(|c| c["id"].as_i64().unwrap())
.collect();
assert_eq!(default, [work]);
// The old default can go now; the new one cannot.
let r = ui
.alice
.delete(&format!("/api/pim/collections/{old}"))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let r = ui
.alice
.delete(&format!("/api/pim/collections/{work}"))
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
// Bob cannot choose Alice's calendar.
let r = ui
.bob
.put_json(
&format!("/api/pim/collections/{work}"),
&json!({"is_default": true}),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn feeds_are_capped_and_never_born_expired() {
let ui = Ui::new().await;
let id = ui
.list(&ui.alice)
.await
.into_iter()
.find(|c| c["is_default"] == true)
.unwrap()["id"]
.as_i64()
.unwrap();
let url = format!("/api/pim/collections/{id}/links");
let r = ui
.alice
.post_json(&url, &json!({"expires_at": "2000-01-01T00:00:00Z"}))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
for _ in 0..50 {
let r = ui.alice.post_json(&url, &json!({})).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
}
let r = ui.alice.post_json(&url, &json!({})).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
▾Mweb/src/i18n.rs
@@ -564,6 +564,7 @@ i18n_keys! {
PIM_PS_DELEGATED = "pim_ps_delegated" => "Delegated",
PIM_PS_NEEDS_ACTION = "pim_ps_needs_action" => "No answer yet",
PIM_PS_TENTATIVE = "pim_ps_tentative" => "Maybe",
PIM_RECEIVES_INVITATIONS = "pim_receives_invitations" => "Receives invitations",
PIM_REMOVE_FROM_LIST = "pim_remove_from_list" => "Remove from my list",
PIM_REMOVE_MSG = "pim_remove_msg" => "You no longer see it. The owner can share it again.",
PIM_REMOVED = "pim_removed" => "Removed from your list",
@@ -1429,6 +1430,7 @@ const DE: &[(&str, &str)] = &[
("pim_ps_delegated", "Delegiert"),
("pim_ps_needs_action", "Noch keine Antwort"),
("pim_ps_tentative", "Vielleicht"),
("pim_receives_invitations", "Empfängt Einladungen"),
("pim_remove_from_list", "Aus meiner Liste entfernen"),
(
"pim_remove_msg",
@@ -2417,6 +2419,7 @@ const FR: &[(&str, &str)] = &[
("pim_ps_delegated", "Délégué"),
("pim_ps_needs_action", "Pas encore de réponse"),
("pim_ps_tentative", "Peut-être"),
("pim_receives_invitations", "Reçoit les invitations"),
("pim_remove_from_list", "Retirer de ma liste"),
(
"pim_remove_msg",
▾Mweb/src/views/calendar.rs
@@ -541,7 +541,10 @@ pub fn CalendarMain(
let refocus = StoredValue::new(false);
Effect::new(move |_| {
tick.track();
collections.track();
// The first answer would come before the collections and go to waste.
if collections.with(Option::is_none) {
return;
}
let CalView::Month(y, m) = view.get() else {
return;
};
@@ -586,7 +589,9 @@ pub fn CalendarMain(
let agenda_seq = StoredValue::new(0u32);
Effect::new(move |_| {
tick.track();
collections.track();
if collections.with(Option::is_none) {
return;
}
if view.get() != CalView::Agenda {
return;
}
@@ -610,7 +615,9 @@ pub fn CalendarMain(
let invitations_seq = StoredValue::new(0u32);
Effect::new(move |_| {
tick.track();
collections.track();
if collections.with(Option::is_none) {
return;
}
let seq = invitations_seq.get_value() + 1;
invitations_seq.set_value(seq);
spawn_local(async move {
@@ -1545,11 +1552,16 @@ fn EventDialog(
.iter()
.find(|a| a.is_owner)
.and_then(|a| a.partstat.clone());
let series = target.recurrence_id.is_some() && d.rrule.is_some();
// An override is one event of its own, whatever the series does.
let series = target.recurrence_id.is_some() && d.rrule.is_some() && !d.is_override;
let only_this = move || chosen.get().unwrap_or(true);
let rid = target.recurrence_id.clone();
let (id, name) = (target.collection_id, target.name.clone());
let load = load.clone();
let done = Callback::new(move |_| {
load();
let _ = on_replied.try_run(());
});
let answer = move |ps: &'static str| {
// Without the choice, the instance alone.
let rid = if !series || chosen.get_untracked().unwrap_or(true) {
@@ -1557,19 +1569,7 @@ fn EventDialog(
} else {
None
};
let load = load.clone();
reply(
toast,
busy,
id,
name.clone(),
rid,
ps,
Callback::new(move |_| {
load();
let _ = on_replied.try_run(());
}),
);
reply(toast, busy, id, name.clone(), rid, ps, done);
};
let url = d
.url
@@ -1588,9 +1588,12 @@ fn EventDialog(
<dd>{fmt_span(&span_at(s, e, d.all_day))}</dd>
})}
{d.rrule.clone().map(|r| {
// An override may sit on another day than the series.
let text = match d.start.as_deref().zip(d.end.as_deref()) {
Some((s, e)) => rrule_text(&r, Some(span_at(s, e, d.all_day).first)),
None => rrule_text(&r, None),
Some((s, e)) if !d.is_override => {
rrule_text(&r, Some(span_at(s, e, d.all_day).first))
}
_ => rrule_text(&r, None),
};
view! {
<dt>{i18n::t(k::PIM_REPEATS)}</dt>
▾Mweb/src/views/contacts.rs
@@ -134,7 +134,7 @@ pub fn ContactsMain(
// One request per pause in typing, and no history entry per key.
timer.set_value(Some(gloo_timers::callback::Timeout::new(300, move || {
let mut l = loc.get_untracked();
if l.section != Section::Contacts {
if l.section != Section::Contacts || l.search == v.trim() {
return;
}
l.search = v.trim().to_string();
▾Mweb/src/views/pim.rs
@@ -681,6 +681,10 @@ fn GeneralSection(
// Sent only when changed: one a client stored may fail the server's check.
let saved_description = StoredValue::new(description.get_untracked());
let (transparent, set_transparent) = signal(info.transparent);
// Only one calendar receives invitations: choose another to change it.
let takes_events = calendar && info.components.iter().any(|c| c == "VEVENT");
let (was_default, set_was_default) = signal(info.is_default);
let (is_default, set_is_default) = signal(info.is_default);
let (busy, set_busy) = signal(false);
let save = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
@@ -694,11 +698,14 @@ fn GeneralSection(
color: Some(color.get().unwrap_or_default()),
description: Some(description.get()).filter(|d| *d != saved_description.get_value()),
transparent: calendar.then(|| transparent.get()),
is_default: (is_default.get() && !was_default.get()).then_some(true),
};
let sent = body.description.clone();
spawn_local(async move {
match api::pim_update_collection(id, body).await {
Ok(_) => {
Ok(info) => {
let _ = set_was_default.try_set(info.is_default);
let _ = set_is_default.try_set(info.is_default);
if let Some(d) = sent {
let _ = saved_description.try_set_value(d);
}
@@ -748,6 +755,17 @@ fn GeneralSection(
{i18n::tr(k::PIM_TRANSPARENT)}
</label>
})}
{takes_events.then(|| view! {
<label class="check-row">
<input
type="checkbox"
disabled=move || was_default.get()
prop:checked=move || is_default.get()
on:change=move |ev| set_is_default.set(event_target_checked(&ev))
/>
{i18n::tr(k::PIM_RECEIVES_INVITATIONS)}
</label>
})}
<div class="pim-section-actions">
<button type="submit" class="btn btn-primary btn-sm" disabled=move || busy.get()>
{move || if busy.get() { i18n::t(k::SAVING) } else { i18n::t(k::SAVE) }}