CalDAV/CardDAV review fixes, round 9
- Scheduling messages share one body per group of attendees, so big meetings cost one copy, not one per attendee - Replies keep the component index and never grow the organizer object past the component limit; dropped parts answer 5.1; one attendee PUT brings at most 1000 new overrides and EXDATE values - Switching a rule between COUNT and UNTIL compares the last instance; attendees may write the same rule in another form; rule ends compare without expanding the whole series - Rule iteration slack is capped for endless rules; long single events and long instances are still found - An expanded object is capped at 16 MiB for the requested selection, and a REPORT answer at 64 MiB of rendered data - Task alarms without a start fire from DUE; birthdays with year 0000 or 1604 have no year; an empty comp selects the whole component; vCard 2.1 bare types label values - A SEQUENCE-only save by a writer without the scheduling privilege keeps the old SEQUENCE; the JSON collection update holds the lock; of two concurrent DELETEs only one succeeds - Tests for each change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mpimdav/src/contact.rs
@@ -74,7 +74,7 @@ pub(crate) fn special_dates(lines: &[String]) -> [Option<(Option<i32>, u32, u32)
/// `(year, month, day)` of a date property: `19800315`, `1980-03-15`,
/// `--0315` or `--03-15`, with or without a time. Apple's
/// `X-APPLE-OMIT-YEAR` marks a placeholder year.
/// `X-APPLE-OMIT-YEAR`, year 1604 and year 0000 mark a placeholder year.
fn date(line: &str) -> Option<(Option<i32>, u32, u32)> {
if param(line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("text")) {
return None;
@@ -99,7 +99,9 @@ fn date(line: &str) -> Option<(Option<i32>, u32, u32)> {
let (month, day) = (md[..2].parse().ok()?, md[2..].parse().ok()?);
// 2000 is a leap year, so February 29 passes.
NaiveDate::from_ymd_opt(2000, month, day)?;
let year = year.filter(|_| param(line, "X-APPLE-OMIT-YEAR").is_none());
let year = year
.filter(|y| ![0, 1604].contains(y))
.filter(|_| param(line, "X-APPLE-OMIT-YEAR").is_none());
if let Some(y) = year {
NaiveDate::from_ymd_opt(y, month, day)?;
}
Mpimdav/src/expand.rs
@@ -346,7 +346,20 @@ fn expand_group(
for (_, rid, t) in &future {
slack = slack.max((t.start.utc() - *rid).abs() + t.length.max());
}
let slack = slack + TimeDelta::days(1);
let wide = slack
.checked_add(&TimeDelta::days(1))
.unwrap_or(TimeDelta::MAX);
let reach = add(window.start, -wide);
// A huge DURATION would run an endless rule to the cap, so the rules
// iterate at most 1000 periods of the fastest rule around the window.
// ponytail: a rule instance longer than that is missed where it starts early.
let cap = mc
.properties(&ICalendarProperty::Rrule)
.chain(mc.properties(&ICalendarProperty::Exrule))
.filter_map(|e| rule(e.values.first()?))
.map(|r| period(r) * 1000)
.min();
let slack = cap.map_or(wide, |c| wide.min(c + TimeDelta::days(1)));
let (from, to) = (add(window.start, -slack), add(window.end, slack));
let (from_local, to_local) = (mz.to_local(from), mz.to_local(to));
@@ -406,7 +419,7 @@ fn expand_group(
let recurs =
mc.has_property(&ICalendarProperty::Rrule) || mc.has_property(&ICalendarProperty::Rdate);
for (key, member) in &set {
if member.utc < from
if member.utc < reach
|| excluded.contains(key)
|| excluded_days.contains(&member.local.date())
|| exact.contains_key(key)
@@ -517,6 +530,23 @@ fn occurrences_capped(
list
}
/// The longest time between two periods of `r`.
fn period(r: &ICalendarRecurrenceRule) -> TimeDelta {
let days = match r.freq {
ICalendarFrequency::Yearly => 366,
ICalendarFrequency::Monthly => 31,
ICalendarFrequency::Weekly => 7,
_ => 1,
};
let unit = match r.freq {
ICalendarFrequency::Hourly => TimeDelta::hours(1),
ICalendarFrequency::Minutely => TimeDelta::minutes(1),
ICalendarFrequency::Secondly => TimeDelta::seconds(1),
_ => TimeDelta::days(days),
};
unit * i32::from(r.interval.unwrap_or(1).max(1))
}
fn push(out: &mut Expansion, window: &Range<DateTime<Utc>>, i: Instance) {
let overlaps = if i.start == i.end {
window.contains(&i.start)
Mpimdav/src/filter.rs
@@ -499,6 +499,21 @@ impl Ctx<'_> {
if r.end == DateTime::<Utc>::MAX_UTC && endless(&self.cal.components[parent]) {
return true;
}
let owner = &self.cal.components[parent];
// A task without DTSTART has no instances. Its alarm can only
// relate to DUE (RFC 4791, 9.9).
if !owner.has_property(&ICalendarProperty::Dtstart) {
return owner
.property(&ICalendarProperty::Due)
.and_then(|e| {
stamp(
&self.zones,
e.values.first()?.as_partial_date_time()?,
e.tz_id(),
)
})
.is_some_and(|due| hit(add(due.utc(), offset)));
}
let from_end = trigger.parameter(&ICalendarParameterName::Related)
== Some(&ICalendarParameterValue::Related(ICalendarRelated::End));
let exp =
Mpimdav/src/itip.rs
@@ -5,7 +5,9 @@
//! them onto its principals.
use std::cell::{OnceCell, RefCell};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use calcard::common::PartialDateTime;
use calcard::icalendar::{
@@ -20,9 +22,10 @@ use xmltree::Element;
use crate::expand::expand;
use crate::filter::TimeRange;
use crate::freebusy::{Busy, Period, merge};
use crate::object::MAX_COMPONENTS;
use crate::text::{fold, logical_lines, name, param_parts, unfold, value};
use crate::xml::{CALDAV, el};
use crate::zone::{Zone, Zones, add_local};
use crate::zone::{Zone, Zones, add, add_local};
/// Whether an address belongs to someone in particular.
pub type Is<'a> = &'a dyn Fn(&str) -> bool;
@@ -68,8 +71,8 @@ pub struct Message {
/// Only other attendees' answers or the SEQUENCE changed. It updates an
/// existing copy, keeps its Schedule-Tag and leaves no inbox entry.
pub quiet: bool,
/// With METHOD.
pub cal: ICalendar,
/// With METHOD. Recipients who see the same thing share one.
pub cal: Arc<ICalendar>,
}
pub fn role(cal: &ICalendar, owner: Is) -> Result<Role, Refused> {
@@ -164,6 +167,9 @@ pub fn answer_horizon(copy: &ICalendar) -> TimeDelta {
TimeDelta::days(if endless { 731 } else { 3653 })
}
/// New overrides and EXDATE values one attendee PUT may bring.
const MAX_ATTENDEE_CHANGES: usize = 1000;
/// Conflicting instances of a series a room declines one by one. Beyond
/// that it declines the series.
const MAX_DECLINED_INSTANCES: usize = 100;
@@ -227,7 +233,7 @@ pub fn auto_answer(
if obj.find(Some(key)).is_some() {
obj.narrow(copy, rid, key, floating);
} else if let Some(inst) = obj.single(rid, floating) {
obj.children_mut().push(inst);
obj.push(inst);
} else {
continue;
}
@@ -287,7 +293,7 @@ pub fn respond(
return None;
}
let inst = obj.single(rid, floating)?;
obj.children_mut().push(inst);
obj.push(inst);
}
Some(Some(key))
}
@@ -338,50 +344,87 @@ pub fn messages(
}
}
}
// Attendees invited to the same components share one view.
// Attendees invited to the same components share one view and one
// message body.
let old_inv = old.as_ref().map(Obj::invitations);
let new_inv = new.as_ref().map(Obj::invitations);
let client: HashSet<String> = new
.iter()
.flat_map(|n| n.comps().flat_map(|c| attendees(&c.c)))
.filter(|e| !server_agent(e))
.filter_map(address)
.map(str::to_ascii_lowercase)
.collect();
let mut old_views: HashMap<Vec<bool>, Option<Vec<Node>>> = HashMap::new();
let mut new_views: HashMap<Vec<bool>, Option<Vec<Node>>> = HashMap::new();
let mut changes: HashMap<(Vec<bool>, Vec<bool>), Change> = HashMap::new();
let mut requests: HashMap<Vec<bool>, Arc<ICalendar>> = HashMap::new();
let mut cancels: HashMap<(Vec<bool>, Vec<bool>), Arc<ICalendar>> = HashMap::new();
let mut out = Vec::new();
for a in who {
let lower = a.to_ascii_lowercase();
let sig = |inv: &Vec<HashSet<String>>| -> Vec<bool> {
inv.iter().map(|s| s.contains(&lower)).collect()
};
let before = old.as_ref().zip(old_inv.as_ref()).and_then(|(o, inv)| {
let s = sig(inv);
let v = old_views.entry(s.clone()).or_insert_with(|| o.view(&s));
Some((o, v.clone()?, s))
});
let after = new.as_ref().zip(new_inv.as_ref()).and_then(|(n, inv)| {
let s = sig(inv);
let v = new_views.entry(s.clone()).or_insert_with(|| n.view(&s));
Some((n, v.clone()?, s))
});
let (method, quiet, comps, src) = match (before, after) {
(Some((src, b, _)), None) => {
let sb = old_inv.as_ref().map(sig);
let sa = new_inv.as_ref().map(sig);
if let (Some(o), Some(s)) = (&old, &sb) {
old_views.entry(s.clone()).or_insert_with(|| o.view(s));
}
if let (Some(n), Some(s)) = (&new, &sa) {
new_views.entry(s.clone()).or_insert_with(|| n.view(s));
}
let before = old
.as_ref()
.zip(sb.as_ref())
.and_then(|(o, s)| Some((o, s, old_views[s].as_ref()?)));
let after = new
.as_ref()
.zip(sa.as_ref())
.and_then(|(n, s)| Some((n, s, new_views[s].as_ref()?)));
let request = |n: &Obj, sa: &Vec<bool>, comps: &Vec<Node>, requests: &mut HashMap<_, _>| {
Arc::clone(
requests
.entry(sa.clone())
.or_insert_with(|| Arc::new(n.envelope(comps.clone(), Method::Request, now))),
)
};
let (method, quiet, cal) = match (before, after) {
(Some((src, sb, b)), None) => {
// A component that lists them with SCHEDULE-AGENT=CLIENT
// now: the client tells them.
let lost: Vec<Node> = b
.into_iter()
.filter(|c| {
!new.as_ref()
.and_then(|n| n.find(src.key(&c.c)))
.is_some_and(|nc| client_scheduled(&nc.c, a))
})
.collect();
if lost.is_empty() {
let lost: Vec<bool> = match client.contains(&lower) {
false => vec![true; b.len()],
true => b
.iter()
.map(|c| {
!new.as_ref()
.and_then(|n| n.find(src.key(&c.c)))
.is_some_and(|nc| client_scheduled(&nc.c, a))
})
.collect(),
};
if !lost.contains(&true) {
continue;
}
(Method::Cancel, false, cancelled(lost), src)
let key = (sb.clone(), lost);
let cal = cancels.entry(key).or_insert_with_key(|(_, lost)| {
let comps = b
.iter()
.zip(lost)
.filter(|(_, l)| **l)
.map(|(c, _)| c.clone());
Arc::new(src.envelope(cancelled(comps.collect()), Method::Cancel, now))
});
(Method::Cancel, false, Arc::clone(cal))
}
(None, Some((n, sa, comps))) => {
(Method::Request, false, request(n, sa, comps, &mut requests))
}
(None, Some((src, comps, _))) => (Method::Request, false, comps, src),
(Some((_, b, sb)), Some((src, comps, sa))) => {
(Some((_, sb, b)), Some((n, sa, comps))) => {
let change = *changes
.entry((sb, sa))
.or_insert_with(|| change(&b, &comps));
.entry((sb.clone(), sa.clone()))
.or_insert_with(|| change(b, comps));
let forced = force.iter().any(|f| f.eq_ignore_ascii_case(a));
let quiet = match change {
Change::Content => false,
@@ -392,7 +435,7 @@ pub fn messages(
Change::Sequence => true,
Change::None => continue,
};
(Method::Request, quiet, comps, src)
(Method::Request, quiet, request(n, sa, comps, &mut requests))
}
(None, None) => continue,
};
@@ -400,7 +443,7 @@ pub fn messages(
to: a.to_string(),
method,
quiet,
cal: src.envelope(comps, method, now),
cal,
});
}
out
@@ -454,6 +497,18 @@ pub fn attend(
.filter_map(|c| next.key(&c.c))
.filter(|k| old.find(Some(*k)).is_none())
.collect();
let excluded = next.master().map_or(0, |m| {
let before = master.map_or_else(HashSet::new, |om| {
old.times(&om.c, &ICalendarProperty::Exdate)
});
next.times(&m.c, &ICalendarProperty::Exdate)
.difference(&before)
.count()
});
// Each becomes a part of the REPLY and an override in the organizer object.
if added.len() + excluded > MAX_ATTENDEE_CHANGES {
return Err(Refused::AttendeeChange);
}
let instances = old.instances(old_cal, &added);
for c in next.comps() {
@@ -463,13 +518,13 @@ pub fn attend(
// The end, not its property: clients rewrite DURATION as DTEND.
let same_times = [
ICalendarProperty::Dtstart,
ICalendarProperty::Rrule,
ICalendarProperty::Rdate,
ICalendarProperty::Exrule,
]
.iter()
.all(|p| old.times(&oc.c, p) == next.times(&c.c, p))
&& old.end(&oc.c) == next.end(&c.c);
&& old.end(&oc.c) == next.end(&c.c)
&& same_rules(&old, &oc.c, &next, &c.c);
let kept_exdates = old
.times(&oc.c, &ICalendarProperty::Exdate)
.is_subset(&next.times(&c.c, &ICalendarProperty::Exdate));
@@ -597,7 +652,7 @@ pub fn attend(
to,
method: Method::Reply,
quiet: false,
cal: next.envelope(replied, Method::Reply, now),
cal: Arc::new(next.envelope(replied, Method::Reply, now)),
}),
};
Ok((next.done(), reply))
@@ -644,7 +699,7 @@ pub fn decline(old: &ICalendar, me: Is, now: DateTime<Utc>) -> Option<Message> {
to: old.organizer()?,
method: Method::Reply,
quiet: false,
cal: old.envelope(comps, Method::Reply, now),
cal: Arc::new(old.envelope(comps, Method::Reply, now)),
})
}
@@ -728,8 +783,7 @@ pub fn receive(copy: Option<&ICalendar>, msg: &Message) -> Option<ICalendar> {
let Some(r) = range(rid) else { continue };
let at = next.position(Some(*k)).or_else(|| {
let n = next.single(DateTime::from_timestamp(*k, 0)?, &Zone::Utc)?;
next.children_mut().push(n);
Some(next.root.children.len() - 1)
Some(next.push(n))
});
let entry = at.and_then(|at| {
next.children_mut()[at]
@@ -780,11 +834,20 @@ pub fn receive(copy: Option<&ICalendar>, msg: &Message) -> Option<ICalendar> {
}
}
/// A REPLY applied to the organizer object. `false` if it changed nothing.
pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool {
/// What a REPLY did to the organizer object.
#[derive(Debug, Default)]
pub struct Applied {
pub changed: bool,
/// Parts left out because the object would pass `MAX_COMPONENTS`.
pub dropped: usize,
}
/// A REPLY applied to the organizer object.
pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> Applied {
let rep = Obj::new(reply);
let mut next = Obj::new(org);
let mut changed = false;
let mut dropped = 0;
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));
@@ -795,6 +858,8 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
.collect();
// A cut-short expansion over all parts falls back to one per part.
let shared = next.instances(org, &missing);
// A reply may not grow the object past what a PUT of it may hold.
let mut size = org.components.len();
for rc in parts {
let key = rep.key(&rc.c);
let at = match next.position(key) {
@@ -803,7 +868,16 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
&& !is_range(&rc.c)
&& let Some(rid) = DateTime::from_timestamp(t, 0)
{
// Narrowing copies the range to the next instance.
if is_range(&next.root.children[at].c)
&& size + nodes(&next.root.children[at]) > MAX_COMPONENTS
{
dropped += 1;
continue;
}
let had = next.root.children.len();
next.narrow(org, rid, t, &Zone::Utc);
size += next.root.children[had..].iter().map(nodes).sum::<usize>();
}
at
}
@@ -824,11 +898,16 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
let Some(inst) = next.single(at, &Zone::Utc) else {
continue;
};
next.children_mut().push(inst);
next.root.children.len() - 1
if size + nodes(&inst) > MAX_COMPONENTS {
dropped += 1;
continue;
}
size += nodes(&inst);
next.push(inst)
}
};
let target = &mut next.children_mut()[at];
// Attendee parameters leave the index as it is.
let target = &mut next.root.children[at];
if sequence(&rc.c) < sequence(&target.c) {
continue;
}
@@ -872,7 +951,7 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
if changed {
*org = next.done();
}
changed
Applied { changed, dropped }
}
// ---------------------------------------------------------------------------
@@ -994,7 +1073,7 @@ fn rescheduled(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComp
rules(nc, &ICalendarProperty::Exrule),
);
let exrule_changed = !exrules.0.is_empty() && exrules.0 != exrules.1;
moved || reinstated || exrule_changed || rules_grew(new, oc, nc)
moved || reinstated || exrule_changed || rules_grew(oc, new, nc)
}
/// A rule in one form: BY lists sorted, INTERVAL=1 as absent, and WKST
@@ -1038,7 +1117,7 @@ fn rules(c: &ICalendarComponent, prop: &ICalendarProperty) -> Vec<ICalendarRecur
v
}
fn rules_grew(obj: &Obj, oc: &ICalendarComponent, nc: &ICalendarComponent) -> bool {
fn rules_grew(oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bool {
let rules = |c| rules(c, &ICalendarProperty::Rrule);
let (o, n) = (rules(oc), rules(nc));
if o == n {
@@ -1050,25 +1129,80 @@ fn rules_grew(obj: &Obj, oc: &ICalendarComponent, nc: &ICalendarComponent) -> bo
([o], [n]) => (o, n),
_ => return true,
};
let unbounded = |r: &calcard::icalendar::ICalendarRecurrenceRule| {
let mut r = r.clone();
r.until = None;
r.count = None;
r
};
if unbounded(o) != unbounded(n) {
return true;
}
// A DATE UNTIL includes its whole day.
let end =
|u: &PartialDateTime| Some(obj.at(u, None)? + if u.hour.is_none() { 86399 } else { 0 });
let shorter = match (&o.until, &n.until, o.count, n.count) {
(Some(ou), Some(nu), _, _) => end(nu) <= end(ou),
(_, _, Some(oc), Some(nc)) => nc <= oc,
(None, _, None, _) => true,
_ => false,
match (bounded(o), bounded(n)) {
(_, false) => true,
(false, true) => false,
(true, true) => new
.compare_ends(nc, o, n)
.is_none_or(|c| c == Ordering::Greater),
}
}
/// Whether an attendee's copy keeps the organizer's rule. Clients rewrite
/// UNTIL, swap COUNT for UNTIL, or name the start's weekday, so forms that
/// may give the same instances compare by them.
fn same_rules(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bool {
let (o, n) = (
rules(oc, &ICalendarProperty::Rrule),
rules(nc, &ICalendarProperty::Rrule),
);
if o == n {
return true;
}
let ([o], [n]) = (&o[..], &n[..]) else {
return false;
};
!shorter
let loose = |r: &ICalendarRecurrenceRule| {
let mut r = unbounded(r);
if r.freq == ICalendarFrequency::Weekly {
r.byday.clear();
}
r
};
if loose(o) != loose(n) || bounded(o) != bounded(n) {
return false;
}
// Weekdays show within a year.
if unbounded(o) != unbounded(n) {
let starts = |obj: &Obj, c, r: &ICalendarRecurrenceRule| {
let c = with_rule(c, unbounded(r));
let start = obj.start(&c)?;
obj.rule_starts(c, start..add(start, TimeDelta::days(400)))
};
let a = starts(old, oc, o);
if a.is_none() || a != starts(new, nc, n) {
return false;
}
}
!bounded(o) || new.compare_ends(nc, o, n) == Some(Ordering::Equal)
}
/// `c` with `r` as its only RRULE.
fn with_rule(c: &ICalendarComponent, r: ICalendarRecurrenceRule) -> ICalendarComponent {
let mut c = c.clone();
let mut first = true;
c.entries.retain_mut(|e| {
if e.name != ICalendarProperty::Rrule {
return true;
}
e.values = vec![ICalendarValue::RecurrenceRule(Box::new(r.clone()))];
std::mem::take(&mut first)
});
c
}
fn unbounded(r: &ICalendarRecurrenceRule) -> ICalendarRecurrenceRule {
let mut r = r.clone();
r.until = None;
r.count = None;
r
}
fn bounded(r: &ICalendarRecurrenceRule) -> bool {
r.count.is_some() || r.until.is_some()
}
fn cancelled(comps: Vec<Node>) -> Vec<Node> {
@@ -1247,6 +1381,11 @@ fn node(cal: &ICalendar, i: usize) -> Node {
Node { c, children }
}
/// How many components `n` flattens to.
fn nodes(n: &Node) -> usize {
1 + n.children.iter().map(nodes).sum::<usize>()
}
fn flatten(n: &Node, out: &mut Vec<ICalendarComponent>) -> u32 {
let at = out.len();
out.push(n.c.clone());
@@ -1260,7 +1399,7 @@ fn flatten(n: &Node, out: &mut Vec<ICalendarComponent>) -> u32 {
/// A calendar object: the VCALENDAR with its time zones and components.
struct Obj {
/// Change its children only through `children_mut` or `comps_mut`, which
/// drop `index`.
/// drop `index`, or `push`, which keeps it.
root: Node,
zones: Zones,
index: OnceCell<Index>,
@@ -1324,6 +1463,22 @@ impl Obj {
&mut self.root.children
}
/// Appends a child and returns where it is.
fn push(&mut self, n: Node) -> usize {
let at = self.root.children.len();
let key = is_scheduled(&n.c).then(|| self.key(&n.c));
let range = is_range(&n.c);
self.root.children.push(n);
if let (Some(ix), Some(key)) = (self.index.get_mut(), key) {
ix.by_key.entry(key).or_insert(at);
if let Some(k) = key.filter(|_| range) {
let p = ix.ranges.partition_point(|(r, _)| *r <= k);
ix.ranges.insert(p, (k, at));
}
}
at
}
fn done(self) -> ICalendar {
let mut components = Vec::new();
flatten(&self.root, &mut components);
@@ -1531,6 +1686,109 @@ impl Obj {
let (Some(m), Some(at)) = (self.master(), DateTime::from_timestamp(key, 0)) else {
return false;
};
let window = at - TimeDelta::days(1)..at + TimeDelta::days(1);
expand(&self.alone(m.c.clone()), window, Zone::Utc)
.instances
.iter()
.any(|i| i.recurrence_id == Some(at))
}
/// The DTSTART of `c` as an instant.
fn start(&self, c: &ICalendarComponent) -> Option<DateTime<Utc>> {
DateTime::from_timestamp(self.instant(c.property(&ICalendarProperty::Dtstart)?)?, 0)
}
/// The starts the RRULE of `c` gives in `window`, without RDATE, EXDATE
/// and EXRULE. `None` if the expansion was cut short.
fn rule_starts(
&self,
mut c: ICalendarComponent,
window: std::ops::Range<DateTime<Utc>>,
) -> Option<Vec<i64>> {
c.entries.retain(|e| {
!matches!(
e.name,
ICalendarProperty::Rdate
| ICalendarProperty::Exdate
| ICalendarProperty::Exrule
| ICalendarProperty::RecurrenceId
)
});
let e = expand(&self.alone(c), window, Zone::Utc);
if e.truncated {
return None;
}
let mut v: Vec<i64> = e
.instances
.iter()
.map(|i| i.recurrence_id.unwrap_or(i.start).timestamp())
.collect();
v.sort_unstable();
Some(v)
}
/// How the instances `n` keeps compare with those `o` keeps, for rules of
/// `c` that differ only in COUNT or UNTIL. `Greater` if `n` keeps more.
/// Only COUNT, or the gap between two UNTILs, is expanded.
fn compare_ends(
&self,
c: &ICalendarComponent,
o: &ICalendarRecurrenceRule,
n: &ICalendarRecurrenceRule,
) -> Option<Ordering> {
// A DATE UNTIL includes its whole day.
let end = |u: &PartialDateTime| {
Some(self.at(u, None)? + if u.hour.is_none() { 86399 } else { 0 })
};
let start = self.start(c)?;
// The last start COUNT keeps and the one after it.
let counted = |k: u32| {
let mut r = o.clone();
r.until = None;
r.count = Some(k + 1);
let v = self.rule_starts(with_rule(c, r), start..DateTime::<Utc>::MAX_UTC)?;
Some((
v.get((k as usize).checked_sub(1)?).copied(),
v.get(k as usize).copied(),
))
};
// Whether COUNT keeps more (`Greater`) than an UNTIL at `u`.
let count_vs_until = |k: u32, u: i64| {
let (last, next) = counted(k)?;
Some(if last.is_some_and(|l| l > u) {
Ordering::Greater
} else if next.is_some_and(|x| x <= u) {
Ordering::Less
} else {
Ordering::Equal
})
};
match (o.count, &o.until, n.count, &n.until) {
(Some(a), None, Some(b), None) => Some(b.cmp(&a)),
(None, Some(a), None, Some(b)) => {
let (a, b) = (end(a)?, end(b)?);
if a == b {
return Some(Ordering::Equal);
}
let (lo, hi) = (a.min(b), a.max(b));
let at = |t: i64| DateTime::from_timestamp(t + 1, 0);
let mut r = o.clone();
r.until = None;
let between = self.rule_starts(with_rule(c, r), at(lo)?..at(hi)?);
// The window also holds instances that only overlap it.
Some(match between {
Some(v) if v.iter().all(|t| *t <= lo || *t > hi) => Ordering::Equal,
_ => b.cmp(&a),
})
}
(Some(k), None, None, Some(u)) => count_vs_until(k, end(u)?).map(Ordering::reverse),
(None, Some(u), Some(k), None) => count_vs_until(k, end(u)?),
_ => None,
}
}
/// A calendar of `c` alone, with this object's time zones.
fn alone(&self, c: ICalendarComponent) -> ICalendar {
let mut root = Node {
c: self.root.c.clone(),
children: self
@@ -1541,14 +1799,13 @@ impl Obj {
.cloned()
.collect(),
};
root.children.push(m.clone());
root.children.push(Node {
c,
children: Vec::new(),
});
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))
ICalendar { components }
}
/// What instance `key` copies: the THISANDFUTURE override that moves it,
@@ -1832,7 +2089,7 @@ impl Obj {
{
set_param(e, ICalendarParameterName::Range, range);
}
self.children_mut().push(n);
self.push(n);
}
if let Some(e) = self.children_mut()[at]
.c
Mpimdav/src/object.rs
@@ -175,7 +175,7 @@ const MAX_COUNT: u32 = 100_000;
const MAX_COUNT_SUB_DAILY: u32 = 10_000;
/// Scheduling compares attendees and components pairwise.
const MAX_ATTENDEES: usize = 2000;
const MAX_COMPONENTS: usize = 4000;
pub(crate) const MAX_COMPONENTS: usize = 4000;
/// Card views look up labels and groups across lines.
const MAX_CARD_LINES: usize = 10_000;
Mpimdav/src/render.rs
@@ -24,6 +24,9 @@ use crate::zone::{Zone, Zones};
/// Instances one object may expand into in one response.
const MAX_EXPANDED: usize = 10_000;
/// Bytes one object may expand into in one response.
const MAX_EXPANDED_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CalendarData {
/// `None` returns every component and property.
@@ -56,7 +59,8 @@ pub struct AddressData {
pub version: Option<VCardVersion>,
}
/// The expansion would exceed [`MAX_EXPANDED`] instances.
/// The expansion would exceed [`MAX_EXPANDED`] instances or
/// [`MAX_EXPANDED_BYTES`].
#[derive(Debug, PartialEq, Eq)]
pub struct TooManyInstances;
@@ -85,8 +89,10 @@ pub fn calendar_request(e: &Element) -> Result<CalendarData, Refused> {
fn comp_select(e: &Element) -> Result<CompSelect, Refused> {
let name = e.attributes.get("name").ok_or(Refused::Invalid)?;
let has = |local: &str| elements(e).any(|c| Name::of(c).is(CALDAV, local));
let props = (!has("allprop")).then(|| prop_selects(e, CALDAV));
let comps = match has("allcomp") {
// Clients send an empty `<comp name="VTIMEZONE"/>` for the whole zone.
let all = !["allprop", "prop", "allcomp", "comp"].into_iter().any(has);
let props = (!all && !has("allprop")).then(|| prop_selects(e, CALDAV));
let comps = match all || has("allcomp") {
true => None,
false => Some(
elements(e)
@@ -152,7 +158,7 @@ pub fn calendar_data(
};
let mut instances = 0;
if let Some(r) = &req.expand {
(cal, instances) = expanded(&cal, r, floating)?;
(cal, instances) = expanded(&cal, r, floating, req.comp.as_ref())?;
}
if let Some(r) = &req.limit_recurrence {
cal = limit_recurrence(&cal, r, floating);
@@ -174,12 +180,35 @@ fn expanded(
cal: &ICalendar,
range: &TimeRange,
floating: &Zone,
sel: Option<&CompSelect>,
) -> Result<(ICalendar, usize), TooManyInstances> {
let exp = expand(cal, range.clone(), floating.clone());
let count = exp.instances.len();
if exp.truncated || count > MAX_EXPANDED {
return Err(TooManyInstances);
}
// Each instance copies its component, so many attendees times many
// instances can be huge before anything is written.
let mut sizes = HashMap::new();
let mut bytes = 0;
for x in &exp.instances {
// Measured as written: with the requested selection.
bytes += *sizes.entry(x.component).or_insert_with(|| {
let mut one = vec![root(cal)];
let at = copy(cal, x.component, None, &mut one);
one[0].component_ids.push(at);
let mut one = ICalendar { components: one };
if let Some(sel) = sel {
let mut picked = Vec::new();
copy(&one, 0, Some(sel), &mut picked);
one = ICalendar { components: picked };
}
one.to_string().len()
});
if bytes > MAX_EXPANDED_BYTES {
return Err(TooManyInstances);
}
}
let zones = Zones::new(cal, floating.clone());
let mut out = vec![root(cal)];
for x in exp.instances {
Mpimdav/src/view.rs
@@ -11,7 +11,19 @@ use chrono::{DateTime, TimeDelta, Utc};
use crate::expand::{Instance, expand};
use crate::itip::Is;
use crate::text::{group, logical_lines, param, param_parts, prop, unescape_text, unfold, value};
use crate::zone::{Zone, Zones};
use crate::zone::{Zone, Zones, add};
/// Parameter words that say nothing a reader needs.
const NOT_LABELS: [&str; 8] = [
"internet",
"pref",
"voice",
"x400",
"quoted-printable",
"base64",
"8bit",
"7bit",
];
#[derive(Debug, Clone, PartialEq)]
pub struct Person {
@@ -205,12 +217,12 @@ pub fn instance_for(
// A day each side: the move keeps wall-clock time, so a DST change
// between the instances shifts it by up to an hour.
let day = TimeDelta::days(1);
rid + low - day..rid + high + day
add(rid, low - day)..add(rid, high + day)
}
// An override, or an object that does not recur, starts at its DTSTART.
_ => {
let at = instant(&zones, c.property(&ICalendarProperty::Dtstart)?)?;
at..at + TimeDelta::seconds(1)
at..add(at, TimeDelta::seconds(1))
}
};
expand(cal, window, floating.clone())
@@ -281,13 +293,15 @@ pub fn card(vcard: &str) -> Card {
apple.or_else(|| {
let types: Vec<String> = param_parts(l)
.into_iter()
.filter_map(|p| {
let (k, v) = p.split_once('=')?;
k.trim().eq_ignore_ascii_case("TYPE").then_some(v)
// vCard 2.1 writes bare types, and bare encodings next to
// them: `ADR;HOME;QUOTED-PRINTABLE:`.
.filter_map(|p| match p.split_once('=') {
Some((k, v)) => k.trim().eq_ignore_ascii_case("TYPE").then_some(v),
None => Some(p),
})
.flat_map(|v| v.trim_matches('"').split(','))
.map(|t| t.trim().to_ascii_lowercase())
.filter(|t| !["internet", "pref", "voice", "x400"].contains(&t.as_str()))
.filter(|t| !NOT_LABELS.contains(&t.as_str()))
.filter(|t| !t.is_empty())
.collect();
(!types.is_empty()).then(|| types.join(", "))
Mpimdav/tests/contact.rs
@@ -63,6 +63,8 @@ fn birthday_forms() {
"BDAY:--0315\n",
"BDAY:--03-15\n",
"BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\n",
"BDAY:1604-03-15\n",
"BDAY:0000-03-15\n",
] {
let (_, ics) = &contact::dates(&card(yearless), "k")[0];
assert!(ics.contains("SUMMARY:🎂 Anna Berg\r\n"), "{yearless}");
@@ -147,6 +149,8 @@ fn the_card_view_reads_apple_dates() {
assert_eq!(view.anniversary.as_deref(), Some("2001-06-01"));
let plain = pimdav::view::card(&card("BDAY:19800315\n"));
assert_eq!(plain.birthday.as_deref(), Some("1980-03-15"));
let android = pimdav::view::card(&card("BDAY:0000-03-15\n"));
assert_eq!(android.birthday.as_deref(), Some("--03-15"));
}
#[test]
@@ -165,3 +169,15 @@ fn many_labelled_emails_stay_linear() {
assert_eq!(view.emails.len(), 5000);
assert_eq!(view.emails[4999].label.as_deref(), Some("work"));
}
#[test]
fn vcard_2_1_bare_types_label_values() {
let view = pimdav::view::card(&card(
"TEL;CELL:+49 1\nEMAIL;HOME;INTERNET:a@x\nTEL;WORK;QUOTED-PRINTABLE:+49 2\n\
EMAIL;OTHER;CHARSET=UTF-8;8BIT:b@x\n",
));
assert_eq!(view.phones[0].label.as_deref(), Some("cell"));
assert_eq!(view.emails[0].label.as_deref(), Some("home"));
assert_eq!(view.phones[1].label.as_deref(), Some("work"));
assert_eq!(view.emails[1].label.as_deref(), Some("other"));
}
Mpimdav/tests/expand.rs
@@ -490,3 +490,45 @@ fn unused_time_zones_cost_nothing() {
);
assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed());
}
#[test]
fn a_huge_duration_does_not_expand_an_endless_rule_to_the_cap() {
let body = event(
"a",
"DTSTART:20260101T100000Z\r\nDURATION:P4294967295W\r\nRRULE:FREQ=DAILY\r\n",
);
let ics = format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n");
let cal = ICalendar::parse(&ics).unwrap();
let t = std::time::Instant::now();
let out = expand(
&cal,
utc("2026-06-01T00:00")..utc("2026-06-02T00:00"),
Zone::Utc,
);
assert!(!out.truncated);
assert!(!out.instances.is_empty());
assert!(t.elapsed().as_millis() < 500, "{:?}", t.elapsed());
}
#[test]
fn instances_longer_than_a_year_are_found_inside() {
let window = utc("2026-03-01T00:00")..utc("2026-03-02T00:00");
for (props, starts) in [
(
"DTSTART;VALUE=DATE:20250101\r\nDTEND;VALUE=DATE:20270101\r\n",
"2025-01-01T00:00",
),
(
"DTSTART;VALUE=DATE:20250101\r\nDTEND;VALUE=DATE:20270101\r\nRRULE:FREQ=YEARLY;INTERVAL=3\r\n",
"2025-01-01T00:00",
),
] {
let ics = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{}END:VCALENDAR\r\n",
event("a", props)
);
let out = expand(&ICalendar::parse(&ics).unwrap(), window.clone(), Zone::Utc);
let found: Vec<_> = out.instances.iter().map(|i| i.start).collect();
assert_eq!(found, [utc(starts)], "{props}");
}
}
Mpimdav/tests/itip.rs
@@ -252,7 +252,7 @@ fn accept_and_apply_reply() {
);
let mut org = org;
assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)).changed);
let org = text(&org);
assert!(
org.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
@@ -279,7 +279,7 @@ fn declining_one_instance() {
let reply = reply.unwrap();
assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
let mut org = org;
assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)).changed);
let org_text = text(&org);
assert!(
org_text.contains("RECURRENCE-ID:20260112T100000Z"),
@@ -324,13 +324,12 @@ END:VEVENT
let mut org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
// A Tuesday, and a Monday after COUNT ends.
for rid in ["20260113T100000Z", "20260202T100000Z"] {
assert!(!itip::apply_reply(&mut org, &reply(rid), &is(BOB)), "{rid}");
assert!(
!itip::apply_reply(&mut org, &reply(rid), &is(BOB)).changed,
"{rid}"
);
}
assert!(itip::apply_reply(
&mut org,
&reply("20260112T100000Z"),
&is(BOB)
));
assert!(itip::apply_reply(&mut org, &reply("20260112T100000Z"), &is(BOB)).changed);
}
#[test]
@@ -571,7 +570,7 @@ fn a_room_declines_one_instance_a_this_and_future_override_moves() {
// organizer's copy takes it.
let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap();
let mut org = copy.clone();
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)));
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed);
let org = text(&org);
assert!(org.contains("DTSTART:20260119T120000Z"), "{org}");
}
@@ -667,7 +666,7 @@ fn a_room_declines_only_the_first_instance_of_a_this_and_future_override() {
// The organizer's copy splits its range the same way.
let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap();
let mut org = copy.clone();
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)));
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed);
let org = text(&org);
assert!(
org.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
@@ -885,10 +884,10 @@ fn a_cancelled_range_cancels_the_later_instances() {
to: BOB.to_string(),
method: Method::Cancel,
quiet: false,
cal: cal(&format!(
cal: std::sync::Arc::new(cal(&format!(
"METHOD:CANCEL\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
DTSTART:20260112T100000Z\nSEQUENCE:1\nSTATUS:CANCELLED\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
)),
))),
};
use pimdav::calcard::icalendar::{ICalendarProperty, ICalendarStatus, ICalendarValue};
let got = itip::receive(Some(©), &msg).unwrap();
@@ -943,7 +942,7 @@ fn a_reply_status_keeps_only_its_code() {
"METHOD:REPLY\nBEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\n\
ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nREQUEST-STATUS:2.0\",x:EVIL;Success\nEND:VEVENT\n"
));
assert!(itip::apply_reply(&mut org, &reply, &is(BOB)));
assert!(itip::apply_reply(&mut org, &reply, &is(BOB)).changed);
let org = text(&org);
assert!(
org.contains(&format!("SCHEDULE-STATUS=2.0:{BOB}")) && !org.contains("EVIL"),
@@ -1249,7 +1248,8 @@ fn an_override_of_no_instance_is_refused_and_many_overrides_stay_fast() {
.format("%Y%m%dT100000Z")
.to_string()
};
let many: String = (0..3000)
// At most this many changes in one PUT.
let many: String = (0..1000)
.map(|i| {
over(&day(i)).replace(
&format!("ATTENDEE:{BOB}"),
@@ -1266,7 +1266,7 @@ fn an_override_of_no_instance_is_refused_and_many_overrides_stay_fast() {
)
.unwrap();
let mut org = cal(&series);
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
}
@@ -1290,7 +1290,7 @@ fn far_apart_replies_on_a_dense_series_all_land() {
);
let (_, reply) = itip::attend(&cal(&series), cal(©), &is(BOB), now()).unwrap();
let mut org = cal(&series);
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
assert_eq!(
text(&org).matches("PARTSTAT=DECLINED").count(),
2,
@@ -1452,5 +1452,214 @@ fn a_sequence_bump_reaches_the_copies_quietly() {
let (_, reply) = itip::attend(©, declined, &is(BOB), now()).unwrap();
assert!(reply.is_some(), "{}", text(©));
let mut org = store.unwrap();
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
}
#[test]
fn many_attendees_share_one_message_body() {
let people: String = (0..2000)
.map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n"))
.collect();
let new = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
SUMMARY:All hands\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n{people}END:VEVENT\n"
));
let t = std::time::Instant::now();
let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now());
assert_eq!(msgs.len(), 2000);
assert!(
msgs.iter()
.all(|m| std::sync::Arc::ptr_eq(&m.cal, &msgs[0].cal))
);
let store = store.unwrap();
let (_, again) = itip::organize(Some(&store), Some(store.clone()), &is(ALICE), now());
assert!(again.is_empty());
assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed());
}
/// An endless daily series alice organizes, bob's copy of it, and bob's
/// copy with `n` instances deleted.
fn endless_with_exdates(n: i64) -> (ICalendar, ICalendar, ICalendar) {
let series = |bob: &str, extra: &str| {
cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
ATTENDEE;PARTSTAT={bob}:{BOB}\n{extra}END:VEVENT\n"
))
};
let days: Vec<String> = (1..=n)
.map(|i| {
(chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
.format("%Y%m%dT100000Z")
.to_string()
})
.collect();
let exdate = format!("EXDATE:{}\n", days.join(","));
(
series("ACCEPTED", ""),
series("ACCEPTED", ""),
series("ACCEPTED", &exdate),
)
}
#[test]
fn an_attendee_may_delete_many_instances_but_not_without_end() {
let (_, copy, many) = endless_with_exdates(8000);
assert_eq!(
itip::attend(©, many, &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
let (mut org, copy, some) = endless_with_exdates(1000);
let t = std::time::Instant::now();
let (_, reply) = itip::attend(©, some, &is(BOB), now()).unwrap();
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
assert_eq!(org.components.len(), 1002);
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
}
#[test]
fn a_reply_never_grows_the_object_past_the_limit() {
let (mut org, copy, some) = endless_with_exdates(1000);
// 3101 components before the reply: the VCALENDAR, the master and 3099
// overrides of later days.
let overrides: String = (2000..5099)
.map(|i| {
let d = (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()
+ chrono::TimeDelta::days(i))
.format("%Y%m%dT100000Z");
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{d}\nDTSTART:{d}\nDTEND:{d}\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
)
})
.collect();
org = ICalendar::parse(text(&org).replace(
"END:VCALENDAR\r\n",
&format!("{}END:VCALENDAR\r\n", overrides.replace('\n', "\r\n")),
))
.unwrap();
assert_eq!(org.components.len(), 3101);
let (_, reply) = itip::attend(©, some, &is(BOB), now()).unwrap();
let applied = itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB));
assert!(applied.changed);
assert!(applied.dropped > 0);
assert_eq!(org.components.len(), 4000);
}
#[test]
fn narrowing_a_range_never_grows_the_object_past_the_limit() {
let day = |i: i64| {
(chrono::NaiveDate::from_ymd_opt(2026, 2, 1).unwrap() + chrono::TimeDelta::days(i))
.format("%Y%m%dT100000Z")
.to_string()
};
let part = |rid: &str, extra: &str, bob: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID{extra}:{rid}\nDTSTART:{rid}\nDTEND:{rid}\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
)
};
// 3993 components: the VCALENDAR, the master, a range from February 1
// and 3990 plain overrides well after it.
let padding: String = (2000..5990)
.map(|i| part(&day(i), "", "ACCEPTED"))
.collect();
let mut org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n{}{padding}",
part(&day(0), ";RANGE=THISANDFUTURE", "ACCEPTED")
));
assert_eq!(org.components.len(), 3993);
// Each part for one instance under the range splits it again.
let parts: String = (0..20).map(|i| part(&day(i), "", "DECLINED")).collect();
let reply = ICalendar::parse(
format!(
"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//t//t//EN\nMETHOD:REPLY\n{parts}END:VCALENDAR\n"
)
.replace('\n', "\r\n"),
)
.unwrap();
let applied = itip::apply_reply(&mut org, &reply, &is(BOB));
assert!(applied.changed);
assert!(applied.dropped > 0);
assert!(org.components.len() <= 4000, "{}", org.components.len());
}
/// What alice's save of `new` over `old` does to bob's ACCEPTED.
fn keeps_bobs_answer(old_rule: &str, new_rule: &str) -> bool {
let series = |rule: &str| {
let m = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")));
ICalendar::parse(m.replace("COUNT=4", rule)).unwrap()
};
let (store, _) = itip::organize(
Some(&series(old_rule)),
Some(series(new_rule)),
&is(ALICE),
now(),
);
text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}"))
}
#[test]
fn count_and_until_compare_by_the_last_instance() {
// The meeting is on Mondays from January 5.
assert!(keeps_bobs_answer("COUNT=4", "UNTIL=20260119T100000Z"));
assert!(keeps_bobs_answer("UNTIL=20260126T100000Z", "COUNT=3"));
assert!(keeps_bobs_answer("COUNT=4", "COUNT=3"));
assert!(!keeps_bobs_answer("COUNT=3", "UNTIL=20260202T100000Z"));
}
#[test]
fn an_attendee_may_write_the_same_rule_in_another_form() {
let answer = |old_rule: &str, new_rule: &str| {
let copy = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
.replace("COUNT=4", old_rule);
let answered = copy
.replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED")
.replace(old_rule, new_rule);
itip::attend(
&ICalendar::parse(©).unwrap(),
ICalendar::parse(&answered).unwrap(),
&is(BOB),
now(),
)
.map(|_| ())
};
assert_eq!(answer("UNTIL=20260126", "UNTIL=20260126T100000Z"), Ok(()));
assert_eq!(answer("COUNT=4", "COUNT=4;BYDAY=MO"), Ok(()));
assert_eq!(answer("COUNT=4", "UNTIL=20260126T100000Z"), Ok(()));
assert_eq!(
answer("COUNT=4", "UNTIL=20260202T100000Z"),
Err(Refused::AttendeeChange)
);
assert_eq!(
answer("COUNT=4", "COUNT=4;BYDAY=MO,TU"),
Err(Refused::AttendeeChange)
);
}
#[test]
fn moving_a_far_until_costs_only_the_gap() {
let series = |until: &str, bob: &str| {
cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T100100Z\n\
RRULE:FREQ=MINUTELY;UNTIL={until}\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
ATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
))
};
let t = std::time::Instant::now();
let (store, _) = itip::organize(
Some(&series("99991231T000000Z", "ACCEPTED")),
Some(series("99991230T000000Z", "ACCEPTED")),
&is(ALICE),
now(),
);
assert!(text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")));
// A DATE end that keeps the same instances: none falls between the two.
let copy = series("99991230T235930Z", "NEEDS-ACTION");
let answered = series("99991230", "ACCEPTED");
let attended = itip::attend(©, answered, &is(BOB), now());
assert!(attended.is_ok(), "{:?}", attended.err());
assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed());
}
Mpimdav/tests/report.rs
@@ -682,3 +682,75 @@ fn an_instance_moved_by_a_range_takes_the_overrides_component() {
.and_then(|e| e.values.first()?.as_text().map(str::to_string));
assert_eq!(summary.as_deref(), Some("New"));
}
#[test]
fn expand_refuses_an_answer_too_big_to_build() {
let attendees: String = (0..2000)
.map(|i| format!("ATTENDEE:mailto:user{i}@example.com\r\n"))
.collect();
let raw = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:big\r\n\
DTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n"
);
let req = |days: i64| pimdav::render::CalendarData {
expand: Some(
utc("2026-01-01T00:00:00")..utc("2026-01-01T00:00:00") + chrono::TimeDelta::days(days),
),
..Default::default()
};
assert!(calendar_data(&raw, &req(10), &Zone::Utc).is_ok());
assert!(calendar_data(&raw, &req(300), &Zone::Utc).is_err());
// Without the attendees the same expansion is small.
let body = r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-data><c:comp name="VCALENDAR">
<c:comp name="VEVENT"><c:prop name="UID"/><c:prop name="DTSTART"/></c:comp>
</c:comp></c:calendar-data></d:prop>
<d:href>/x.ics</d:href></c:calendar-multiget>"#;
let Ok(Report::CalendarMultiget { props, .. }) = parse(body.as_bytes()) else {
panic!("bad multiget");
};
let picked = pimdav::render::CalendarData {
expand: req(300).expand,
..props.calendar.unwrap()
};
assert!(calendar_data(&raw, &picked, &Zone::Utc).is_ok());
}
#[test]
fn a_task_alarm_without_start_fires_from_due() {
let task = "BEGIN:VTODO\r\nUID:t\r\nDUE:20260110T120000Z\r\n\
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\nEND:VTODO\r\n";
let alarm = |start, end| {
format!(
r#"<c:comp-filter name="VTODO">{}</c:comp-filter>"#,
range("VALARM", start, end)
)
};
assert!(hit(task, &alarm("20260110T114000Z", "20260110T115000Z")));
assert!(!hit(task, &alarm("20260110T115000Z", "20260110T120000Z")));
}
#[test]
fn an_empty_comp_selects_the_whole_component() {
let body = r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-data><c:comp name="VCALENDAR"><c:allprop/>
<c:comp name="VEVENT"><c:prop name="UID"/><c:prop name="DTSTART"/></c:comp>
<c:comp name="VTIMEZONE"/>
</c:comp></c:calendar-data></d:prop>
<d:href>/x.ics</d:href></c:calendar-multiget>"#;
let Ok(Report::CalendarMultiget { props, .. }) = parse(body.as_bytes()) else {
panic!("bad multiget");
};
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\
SUMMARY:Hidden\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let out = calendar_data(raw, &props.calendar.unwrap(), &Zone::Utc)
.unwrap()
.0;
assert!(out.contains("TZID:Europe/Berlin"), "{out}");
assert!(out.contains("TZOFFSETTO:+0100"), "{out}");
assert!(!out.contains("SUMMARY"), "{out}");
}
Mserver/src/api/pim.rs
@@ -856,6 +856,9 @@ pub(super) async fn delete_own(
let db = &state.db;
// A PUT checks under the lock that its collection still exists.
let _lock = pim_schedule::LOCK.lock().await;
if db.pim_collection_by_id(col.id).await?.is_none() {
return Err(ApiError::new(StatusCode::NOT_FOUND, "collection not found"));
}
if kind == PimKind::Calendar && col.slug != INBOX {
if db
.pim_calendar_for(owner, "VEVENT")
@@ -2393,6 +2396,7 @@ impl Cx<'_> {
kind: *kind,
col: col.clone(),
expanded: 0,
rendered: 0,
};
match report {
@@ -2490,7 +2494,7 @@ impl Cx<'_> {
if !filter::matches_card(&card, &filter) {
continue;
}
if limit.is_some_and(|n| responses.len() >= n) {
if limit.is_some_and(|n| responses.len() >= n) || out.full() {
truncated = true;
break;
}
@@ -2720,6 +2724,10 @@ fn search_property_set() -> Response<Body> {
/// Beyond it the answer is cut short with a 507, as for a client limit.
const MAX_EXPANDED_PER_ANSWER: usize = 20_000;
/// Bytes of calendar-data and address-data one REPORT answer may carry.
/// Beyond them it is cut short with a 507 too.
const MAX_RENDERED_PER_ANSWER: usize = 64 * 1024 * 1024;
/// Hrefs and object bytes one multiget loads. Beyond them it answers 507.
const MAX_MULTIGET_HREFS: usize = 1000;
const MAX_MULTIGET_BYTES: usize = 32 * 1024 * 1024;
@@ -2733,6 +2741,8 @@ struct Out {
col: PimCollection,
/// Instances `expand` produced for this answer so far.
expanded: usize,
/// Bytes of object data rendered for this answer so far.
rendered: usize,
}
impl Out {
@@ -2752,20 +2762,20 @@ impl Out {
if let Some(req) = &props.calendar {
let (text, instances) = render::calendar_data(&raw, req, floating)?;
self.expanded += instances;
self.rendered += text.len();
all.push(with_text(el(CALDAV, "calendar-data"), text));
}
if let Some(req) = &props.address {
all.push(with_text(
el(CARDDAV, "address-data"),
render::address_data(&raw, req),
));
let text = render::address_data(&raw, req);
self.rendered += text.len();
all.push(with_text(el(CARDDAV, "address-data"), text));
}
let href = self.space.object(self.kind, &self.col.slug, &o.name);
Ok(select(href, &props.find, all))
}
fn full(&self) -> bool {
self.expanded > MAX_EXPANDED_PER_ANSWER
self.expanded > MAX_EXPANDED_PER_ANSWER || self.rendered > MAX_RENDERED_PER_ANSWER
}
/// The response a query or sync adds when a limit cut it short.
Mserver/src/api/pim_api.rs
@@ -318,6 +318,8 @@ pub async fn update(
AxumPath(id): AxumPath<i64>,
Json(body): Json<UpdatePimCollection>,
) -> Result<Json<PimCollectionInfo>, ApiError> {
// A DELETE in between would leave the default on a removed calendar.
let _lock = pim_schedule::LOCK.lock().await;
let id = own(&state, &auth, id).await?;
let (_, kind, mut col) = state
.db
@@ -368,7 +370,6 @@ pub async fn update(
.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
Mserver/src/api/pim_schedule.rs
@@ -47,6 +47,8 @@ const DELIVERED: &str = "1.2";
const INVALID_USER: &str = "3.7";
/// An address outside this server: there is no iMIP to reach it.
const NO_ROUTE: &str = "5.2";
/// A reply the organizer's object had no room for in full.
const UNDELIVERED: &str = "5.1";
/// The recipient has no calendar for the component.
const REFUSED: &str = "5.3";
/// The recipient holds an object with this UID that is not its copy of the
@@ -261,10 +263,12 @@ pub(crate) async fn put(
false => itip::messages(old, Some(&store), &owns, &force, now),
};
if !messages.is_empty() && !w.may_schedule {
// A SEQUENCE bump alone is no invitation; the copies keep theirs.
if !only_sequence(old, &store, &owns, &force, now) {
// A SEQUENCE bump alone is no invitation. The copies are not
// reached, so the stored object keeps their SEQUENCE.
let Some(same) = only_sequence(old, &store, &owns, &force, now) else {
return Ok(Err(w.refused("schedule-send-invite")));
}
};
store = same;
messages.clear();
}
if !messages.is_empty() {
@@ -293,7 +297,11 @@ pub(crate) async fn put(
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());
itip::stamp_sender(
std::sync::Arc::make_mut(&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);
}
@@ -328,17 +336,16 @@ pub(crate) async fn put(
}))
}
/// Whether `store` differs from `old` for the attendees only in SEQUENCE.
/// `store` with the SEQUENCE values of `old`, if that leaves the attendees
/// nothing to hear.
fn only_sequence(
old: Option<&ICalendar>,
store: &ICalendar,
owns: itip::Is,
force: &[String],
now: DateTime<Utc>,
) -> bool {
let Some(old) = old else {
return false;
};
) -> Option<ICalendar> {
let old = old?;
let key = |c: &ICalendarComponent| {
c.property(&ICalendarProperty::RecurrenceId)
.map(|e| format!("{:?}", e.values))
@@ -355,13 +362,13 @@ fn only_sequence(
.iter_mut()
.filter(|c| c.has_property(&ICalendarProperty::Uid))
{
let Some(sequence) = sequences.get(&key(c)) else {
return false;
};
let sequence = sequences.get(&key(c))?;
c.entries.retain(|e| e.name != ICalendarProperty::Sequence);
c.entries.extend(sequence.clone());
}
itip::messages(Some(old), Some(&same), owns, force, now).is_empty()
itip::messages(Some(old), Some(&same), owns, force, now)
.is_empty()
.then_some(same)
}
/// The resource name the server picks for an object it creates.
@@ -529,7 +536,7 @@ async fn answer_rooms(
let Ok((_, Some(reply))) = itip::attend(&received, answer, &is_room, now) else {
continue;
};
answered |= itip::apply_reply(store, &reply.cal, &is_room);
answered |= itip::apply_reply(store, &reply.cal, &is_room).changed;
ops.push(inbox(organizer, &reply, &component));
}
Ok(answered)
@@ -742,7 +749,8 @@ async fn reply_to(
return Ok(NO_AUTHORITY);
}
let mut after = before.clone();
if itip::apply_reply(&mut after, &m.cal, &replier) {
let applied = itip::apply_reply(&mut after, &m.cal, &replier);
if applied.changed {
let data = after.to_string().into_bytes();
ops.push(PimOp::Put {
collection_id,
@@ -762,7 +770,10 @@ async fn reply_to(
}
}
ops.push(inbox(organizer, m, &component));
Ok(DELIVERED)
Ok(match applied.dropped {
0 => DELIVERED,
_ => UNDELIVERED,
})
}
/// Whether `copy` is `p`'s attendee copy of a meeting `organizer` runs. A
Mserver/tests/api_pim.rs
@@ -910,6 +910,28 @@ async fn expand_answers_are_capped() {
assert_eq!(statuses(&r), [(format!("{CAL}h3.ics"), None)]);
}
#[tokio::test]
async fn expand_answers_are_capped_in_bytes() {
let (env, auth) = setup().await;
let text = "x".repeat(60_000);
for i in 0..6 {
let daily = format!(
"BEGIN:VEVENT\r\nUID:d{i}\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T090000Z\r\n\
DURATION:PT1H\r\nRRULE:FREQ=DAILY\r\nDESCRIPTION:{text}\r\nEND:VEVENT\r\n"
);
put(&env, &auth, &format!("{CAL}d{i}.ics"), &ics(&daily)).await;
}
// 230 instances of 60 KB each stay under the limit of one object. Five
// objects pass the 64 MiB of one answer, so the sixth is cut off.
let expand = r#"<c:calendar-data><c:expand start="20260101T000000Z" end="20260819T000000Z"/></c:calendar-data>"#;
let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260101T000000Z" end="20260819T000000Z"/></c:comp-filter>"#;
let r = req(&env, "REPORT", CAL, &auth, &[], &query(range, expand)).await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let s = statuses(&r);
assert_eq!(s.len(), 6, "{s:?}");
assert_eq!(s[5], (CAL.to_string(), Some(507)));
}
#[tokio::test]
async fn sync_collection() {
let (env, auth) = setup().await;
@@ -2041,6 +2063,23 @@ async fn a_put_racing_the_collection_delete_never_fails_with_500() {
}
}
#[tokio::test]
async fn of_two_deletes_of_one_collection_only_one_succeeds() {
let (env, auth) = setup().await;
let col = "/pim/calendars/alice/twice/";
for _ in 0..20 {
let r = req(&env, "MKCALENDAR", col, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let (a, b) = tokio::join!(
req(&env, "DELETE", col, &auth, &[], ""),
req(&env, "DELETE", col, &auth, &[], ""),
);
let mut got = [a.status, b.status];
got.sort();
assert_eq!(got, [StatusCode::NO_CONTENT, StatusCode::NOT_FOUND]);
}
}
#[tokio::test]
async fn a_proppatch_whose_collection_goes_while_its_body_arrives_is_not_a_500() {
use tower::ServiceExt;
Mserver/tests/api_pim_schedule.rs
@@ -1469,4 +1469,14 @@ async fn a_plain_loan_may_bump_the_sequence() {
.await;
assert!(pim.get("alice", ALICE_EVENT).await.contains("BEGIN:VALARM"));
assert_eq!(pim.inbox("carol").await.len(), sent);
// alice's object keeps the SEQUENCE carol's copy has, so her answer
// still counts.
assert!(!pim.get("alice", ALICE_EVENT).await.contains("SEQUENCE:1"));
let (href, copy) = pim.copy("carol").await;
let accepted = unfold(©.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
let r = pim.req("carol", "PUT", &href, &[], &accepted).await;
assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
let org = unfold(&pim.get("alice", ALICE_EVENT).await);
assert!(org.contains("PARTSTAT=ACCEPTED"), "{org}");
}