CalDAV/CardDAV review fixes, round 8

- Objects are capped at 2000 attendees per component, 4000 components,
  50 zones with 50 rules each and 500 zone rules in all; vCards at
  10,000 lines
- Scheduling work stays near-linear on big objects: an index of
  components, views computed once per group of attendees, cached series
  checks; a huge DURATION no longer panics
- WKST is ignored where it cannot change the instances; clients may
  confirm their own instance; a SEQUENCE-only change reaches the copies
  quietly and does not need the scheduling privilege
- Import stores meetings that are over without sending anything, still
  checked like a PUT
- Contacts: labels and Apple dates are read in linear time; birthdays
  without a year and Apple anniversaries show; vCard 4.0 photos without
  TYPE get their media type
- Instances moved by a THISANDFUTURE override show its text; import
  reads each master's start once; repeated PROPPATCH sets count once
- MKCALENDAR accepts #RGB colors and fractional orders; PROPPATCH on
  lent or generated collections answers per property; Overwrite is
  case-insensitive; Allow and HEAD lengths match the real answers
- Feed links count only unexpired ones, under the lock; the past-expiry
  error is translated
- Tests for each change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit5878a12fa377151b02d6f5ffaf5518159651dd44
Parent6047625
24 files changed, 1117 insertions(+), 239 deletions(-)
▾Mpimdav/README.md
@@ -182,6 +182,15 @@ rule with BYMONTH, that is `1TU` to `5TU`. Otherwise it is `1TU` to `53TU`.
- Components nested more than 4 levels below VCALENDAR fail
`valid-calendar-data`. The deepest standard case, VEVENT > PARTICIPANT >
VLOCATION, has 3. Scheduling and rendering recurse once per level.
- Size limits, each failing `valid-calendar-object-resource`. Real data
stays far below them. Past them, one object would cost minutes per
request.
- At most 4 RRULE and EXRULE lines per component, and 50 per VTIMEZONE.
- COUNT at most 10,000 for SECONDLY, MINUTELY and HOURLY, 100,000
otherwise.
- At most 2,000 ATTENDEE lines per component and 4,000 components per
object.
- A vCard with more than 10,000 lines fails `valid-address-data`.
- XML 1.0 forbids most control characters, also as references. Stored
text keeps them. In a response they become U+FFFD, so one object cannot
break a whole multistatus.
▾Mpimdav/src/bundle.rs
@@ -241,6 +241,19 @@ pub fn split_calendar(
.map(String::as_str)
.collect()
};
// Each master's start, read once: overrides add to its text below.
let starts: HashMap<usize, DateTime<Utc>> = masters
.values()
.filter(|list| list.len() > 1)
.flatten()
.filter_map(|&i| {
let (_, text, ids) = &groups[i];
Some((
i,
instant(&(zones_of(ids) + text), ICalendarProperty::Dtstart)?,
))
})
.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) {
@@ -256,8 +269,7 @@ pub fn split_calendar(
list.iter()
.copied()
.filter_map(|i| {
let (_, text, ids) = &groups[i];
let start = instant(&(zones_of(ids) + text), ICalendarProperty::Dtstart)?;
let start = *starts.get(&i)?;
Some((start, i)).filter(|(s, _)| rid.is_some_and(|r| *s <= r))
})
.max()
▾Mpimdav/src/contact.rs
@@ -7,7 +7,9 @@ use calcard::vcard::{VCard, VCardProperty, VCardValue};
use chrono::NaiveDate;
use sha2::{Digest, Sha256};
use crate::text::{escape_text, fold, logical_lines, name, param, unescape_text, unfold, value};
use crate::text::{
escape_text, fold, group, logical_lines, param, prop, unescape_text, unfold, value,
};
/// The image of a contact's PHOTO: vCard 3 `ENCODING=b` or a vCard 4 `data:`
/// URI. A photo given as a URL is `None`: fetching it would let any contact
@@ -30,39 +32,16 @@ pub fn photo(vcard: &str) -> Option<Vec<u8>> {
/// event cannot carry a changing age.
pub fn dates(vcard: &str, key: &str) -> Vec<(String, String)> {
let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect();
let prop = |l: &str| {
let n = name(l);
n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string())
};
let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string());
let full_name = lines
.iter()
.find(|l| prop(l) == "FN")
.map(|l| unescape_text(value(l)))
.unwrap_or_default();
// Apple writes an anniversary as X-ABDATE, labelled by a sibling line of
// the same group.
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()
.filter(|l| wanted(l))
.find_map(|l| date(l).map(|d| (d, l.as_str())))
};
let birthday = first(&|l| prop(l) == "BDAY");
let anniversary = first(&|l| {
let p = prop(l);
p == "ANNIVERSARY" || p == "X-ANNIVERSARY" || (p == "X-ABDATE" && apple_anniversary(l))
});
let [birthday, anniversary] = special_dates(&lines);
[("BDAY", "🎂", birthday), ("ANNIVERSARY", "💍", anniversary)]
.into_iter()
.filter_map(|(what, sign, found)| {
let ((year, month, day), _) = found?;
let (year, month, day) = found?;
let hash = Sha256::digest(format!("{key}\0{what}"));
let uid: String = hash[..16].iter().map(|b| format!("{b:02x}")).collect();
Some((uid.clone(), event(&uid, sign, &full_name, year, month, day)))
@@ -70,6 +49,29 @@ pub fn dates(vcard: &str, key: &str) -> Vec<(String, String)> {
.collect()
}
/// The first valid birthday and anniversary among a card's unfolded lines,
/// as `(year, month, day)`.
pub(crate) fn special_dates(lines: &[String]) -> [Option<(Option<i32>, u32, u32)>; 2] {
// Apple writes an anniversary as X-ABDATE, labelled by a sibling line of
// the same group.
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 first =
|wanted: &dyn Fn(&str) -> bool| lines.iter().filter(|l| wanted(l)).find_map(|l| date(l));
[
first(&|l| prop(l) == "BDAY"),
first(&|l| {
let p = prop(l);
p == "ANNIVERSARY"
|| p == "X-ANNIVERSARY"
|| (p == "X-ABDATE" && group(l).is_some_and(|g| labelled.contains(&g)))
}),
]
}
/// `(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.
▾Mpimdav/src/itip.rs
@@ -4,14 +4,15 @@
//! Calendar user addresses are compared through closures, so the caller maps
//! them onto its principals.
use std::collections::HashSet;
use std::cell::{OnceCell, RefCell};
use std::collections::{HashMap, HashSet};
use calcard::common::PartialDateTime;
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarDuration, ICalendarEntry,
ICalendarMethod, ICalendarParameter, ICalendarParameterName, ICalendarParameterValue,
ICalendarParticipationStatus, ICalendarProperty, ICalendarRecurrenceRule, ICalendarStatus,
ICalendarValue, ICalendarWeekday, Uri,
ICalendarFrequency, ICalendarMethod, ICalendarParameter, ICalendarParameterName,
ICalendarParameterValue, ICalendarParticipationStatus, ICalendarProperty,
ICalendarRecurrenceRule, ICalendarStatus, ICalendarValue, ICalendarWeekday, Uri,
};
use chrono::{DateTime, TimeDelta, Utc};
use xmltree::Element;
@@ -64,7 +65,7 @@ pub struct Message {
/// The recipient's address, as the object writes it.
pub to: String,
pub method: Method,
/// Only the participation of other attendees changed. It updates an
/// 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.
@@ -226,7 +227,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.root.children.push(inst);
obj.children_mut().push(inst);
} else {
continue;
}
@@ -286,7 +287,7 @@ pub fn respond(
return None;
}
let inst = obj.single(rid, floating)?;
obj.root.children.push(inst);
obj.children_mut().push(inst);
}
Some(Some(key))
}
@@ -325,23 +326,42 @@ pub fn messages(
let old = old.map(Obj::new);
let new = new.map(Obj::new);
let mut who: Vec<&str> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for obj in old.iter().chain(new.iter()) {
for e in obj.comps().flat_map(|c| attendees(&c.c)) {
if let Some(a) = address(e)
&& server_agent(e)
&& !organizer(a)
&& !who.iter().any(|w| w.eq_ignore_ascii_case(a))
&& seen.insert(a.to_ascii_lowercase())
{
who.push(a);
}
}
}
// Attendees invited to the same components share one view.
let old_inv = old.as_ref().map(Obj::invitations);
let new_inv = new.as_ref().map(Obj::invitations);
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 out = Vec::new();
for a in who {
let before = old.as_ref().and_then(|o| Some((o, o.view(a)?)));
let after = new.as_ref().and_then(|n| Some((n, n.view(a)?)));
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) => {
(Some((src, b, _)), None) => {
// A component that lists them with SCHEDULE-AGENT=CLIENT
// now: the client tells them.
let lost: Vec<Node> = b
@@ -357,16 +377,20 @@ pub fn messages(
}
(Method::Cancel, false, cancelled(lost), src)
}
(None, Some((src, comps))) => (Method::Request, false, comps, src),
(Some((_, b)), Some((src, comps))) => {
let quiet = if normalized(&b, true) != normalized(&comps, true) {
false
} else if normalized(&b, false) != normalized(&comps, false) {
true
} else if force.iter().any(|f| f.eq_ignore_ascii_case(a)) {
false
} else {
continue;
(None, Some((src, comps, _))) => (Method::Request, false, comps, src),
(Some((_, b, sb)), Some((src, comps, sa))) => {
let change = *changes
.entry((sb, sa))
.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,
Change::Answers => true,
_ if forced => false,
// Copies carry the new SEQUENCE, or their replies count
// as stale.
Change::Sequence => true,
Change::None => continue,
};
(Method::Request, quiet, comps, src)
}
@@ -460,9 +484,9 @@ pub fn attend(
// An instance the attendee overrides, to set its own status.
None => {
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))
{
if !key.is_some_and(|k| {
master.is_some() && instance(k) && old.plain(&next, &c.c, k, true)
}) {
return Err(Refused::AttendeeChange);
}
}
@@ -487,7 +511,7 @@ pub fn attend(
if next.find(key).is_some() {
continue;
}
let plain = |k: i64| old.plain(&old, &oc.c, k);
let plain = |k: i64| old.plain(&old, &oc.c, k, false);
let allowed =
|k: i64| master.is_none() || !is_range(&oc.c) && (exdated.contains(&k) || plain(k));
if !key.is_some_and(allowed) {
@@ -704,11 +728,11 @@ 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.root.children.push(n);
next.children_mut().push(n);
Some(next.root.children.len() - 1)
});
let entry = at.and_then(|at| {
next.root.children[at]
next.children_mut()[at]
.c
.entries
.iter_mut()
@@ -800,11 +824,11 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> bool
let Some(inst) = next.single(at, &Zone::Utc) else {
continue;
};
next.root.children.push(inst);
next.children_mut().push(inst);
next.root.children.len() - 1
}
};
let target = &mut next.root.children[at];
let target = &mut next.children_mut()[at];
if sequence(&rc.c) < sequence(&target.c) {
continue;
}
@@ -973,7 +997,8 @@ fn rescheduled(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComp
moved || reinstated || exrule_changed || rules_grew(new, oc, nc)
}
/// A rule in one form: BY lists sorted, INTERVAL=1 and WKST=MO as absent.
/// A rule in one form: BY lists sorted, INTERVAL=1 as absent, and WKST
/// absent where it cannot change the instances.
fn canonical(r: &ICalendarRecurrenceRule) -> ICalendarRecurrenceRule {
fn tidy<T: Ord>(v: &mut Vec<T>) {
v.sort();
@@ -990,6 +1015,13 @@ fn canonical(r: &ICalendarRecurrenceRule) -> ICalendarRecurrenceRule {
tidy(&mut r.bymonth);
tidy(&mut r.bysetpos);
r.interval = r.interval.filter(|i| *i > 1);
// WKST only bounds weeks of a WEEKLY rule that skips weeks or picks by
// BYSETPOS, and the weeks of BYWEEKNO (RFC 5545, 3.3.10).
let weeks =
r.freq == ICalendarFrequency::Weekly && (r.interval.is_some() || !r.bysetpos.is_empty());
if !weeks && r.byweekno.is_empty() {
r.wkst = None;
}
r.wkst = r.wkst.filter(|w| *w != ICalendarWeekday::Monday);
r
}
@@ -1059,6 +1091,35 @@ fn cancelled(comps: Vec<Node>) -> Vec<Node> {
.collect()
}
/// How an attendee's view changed.
#[derive(Clone, Copy)]
enum Change {
/// More than the participation of others.
Content,
/// Only the participation of others.
Answers,
/// Only the SEQUENCE.
Sequence,
None,
}
fn change(before: &[Node], after: &[Node]) -> Change {
let sequences = |comps: &[Node]| {
let mut v: Vec<i64> = comps.iter().map(|n| sequence(&n.c)).collect();
v.sort_unstable();
v
};
if normalized(before, true) != normalized(after, true) {
Change::Content
} else if normalized(before, false) != normalized(after, false) {
Change::Answers
} else if sequences(before) != sequences(after) {
Change::Sequence
} else {
Change::None
}
}
/// 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> {
@@ -1198,8 +1259,21 @@ 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`.
root: Node,
zones: Zones,
index: OnceCell<Index>,
}
/// Lookups over `Obj::root`, built on first use.
struct Index {
/// The first scheduled component with each key.
by_key: HashMap<Option<i64>, usize>,
/// THISANDFUTURE overrides by key, ascending.
ranges: Vec<(i64, usize)>,
/// Answers of `Obj::in_series`, which expands.
in_series: RefCell<HashMap<i64, bool>>,
}
impl Obj {
@@ -1218,9 +1292,38 @@ impl Obj {
Obj {
root,
zones: Zones::new(cal, Zone::Utc),
index: OnceCell::new(),
}
}
fn index(&self) -> &Index {
self.index.get_or_init(|| {
let mut by_key = HashMap::new();
let mut ranges = Vec::new();
for (i, n) in self.root.children.iter().enumerate() {
if !is_scheduled(&n.c) {
continue;
}
let key = self.key(&n.c);
by_key.entry(key).or_insert(i);
if let Some(k) = key.filter(|_| is_range(&n.c)) {
ranges.push((k, i));
}
}
ranges.sort_by_key(|(k, _)| *k);
Index {
by_key,
ranges,
in_series: RefCell::default(),
}
})
}
fn children_mut(&mut self) -> &mut Vec<Node> {
self.index.take();
&mut self.root.children
}
fn done(self) -> ICalendar {
let mut components = Vec::new();
flatten(&self.root, &mut components);
@@ -1232,7 +1335,9 @@ impl Obj {
}
fn comps_mut(&mut self) -> impl Iterator<Item = &mut Node> {
self.root.children.iter_mut().filter(|n| is_scheduled(&n.c))
self.children_mut()
.iter_mut()
.filter(|n| is_scheduled(&n.c))
}
fn master(&self) -> Option<&Node> {
@@ -1240,14 +1345,11 @@ impl Obj {
}
fn find(&self, key: Option<i64>) -> Option<&Node> {
self.comps().find(|c| self.key(&c.c) == key)
Some(&self.root.children[self.position(key)?])
}
fn position(&self, key: Option<i64>) -> Option<usize> {
self.root
.children
.iter()
.position(|n| is_scheduled(&n.c) && self.key(&n.c) == key)
self.index().by_key.get(&key).copied()
}
/// The RECURRENCE-ID as an instant; `None` for the master.
@@ -1306,28 +1408,45 @@ impl Obj {
.all(server_agent)
}
/// What attendee `a` gets to see (RFC 6638, 3.2.6): the master with the
/// overrides it is in, and EXDATEs for those it is not in; or just the
/// overrides it is in. A THISANDFUTURE override it is not in ends its
/// series there.
fn view(&self, a: &str) -> Option<Vec<Node>> {
let invited = |c: &ICalendarComponent| {
attendees(c)
.any(|e| address(e).is_some_and(|x| x.eq_ignore_ascii_case(a)) && server_agent(e))
/// Per child of `root`, the addresses it invites through the server,
/// lowercase.
fn invitations(&self) -> Vec<HashSet<String>> {
self.root
.children
.iter()
.map(|n| match is_scheduled(&n.c) {
true => attendees(&n.c)
.filter(|e| server_agent(e))
.filter_map(address)
.map(str::to_ascii_lowercase)
.collect(),
false => HashSet::new(),
})
.collect()
}
/// What an attendee sees (RFC 6638, 3.2.6), given which children invite
/// them. Overrides they are not in become EXDATEs. A THISANDFUTURE
/// override they are not in ends their series.
fn view(&self, invited: &[bool]) -> Option<Vec<Node>> {
let overrides = || {
self.root.children.iter().enumerate().filter(|(_, c)| {
is_scheduled(&c.c) && c.c.has_property(&ICalendarProperty::RecurrenceId)
})
};
let mut out = Vec::new();
let master = self.master().filter(|m| invited(&m.c));
let master = self
.position(None)
.filter(|i| invited[*i])
.map(|i| &self.root.children[i]);
if let Some(m) = master {
let mut m = m.clone();
let mut end: Option<i64> = None;
for o in self
.comps()
.filter(|c| c.c.has_property(&ICalendarProperty::RecurrenceId))
{
for (i, o) in overrides() {
let Some(rid) = o.c.property(&ICalendarProperty::RecurrenceId) else {
continue;
};
if invited(&o.c) {
if invited[i] {
continue;
}
if is_range(&o.c) {
@@ -1358,9 +1477,9 @@ impl Obj {
}
}
out.extend(
self.comps()
.filter(|c| c.c.has_property(&ICalendarProperty::RecurrenceId) && invited(&c.c))
.cloned(),
overrides()
.filter(|(i, _)| invited[*i])
.map(|(_, c)| c.clone()),
);
for n in &mut out {
n.children
@@ -1372,7 +1491,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 {
fn plain(&self, obj: &Obj, c: &ICalendarComponent, key: i64, added: bool) -> bool {
let Some(base) = self.base(key) else {
return false;
};
@@ -1390,11 +1509,25 @@ impl Obj {
&& 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))
&& (c.status() == base.c.status()
|| own_progress(c, &base.c)
// Some clients mark the instance they answer as confirmed.
|| added
&& base.c.status().is_none()
&& c.status() == Some(&ICalendarStatus::Confirmed))
}
/// Whether the master's own rules have an instance at `key`.
fn in_series(&self, key: i64) -> bool {
*self
.index()
.in_series
.borrow_mut()
.entry(key)
.or_insert_with(|| self.expands_to(key))
}
fn expands_to(&self, key: i64) -> bool {
let (Some(m), Some(at)) = (self.master(), DateTime::from_timestamp(key, 0)) else {
return false;
};
@@ -1449,8 +1582,8 @@ impl Obj {
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 days = TimeDelta::try_days(i64::from(d.weeks) * 7 + i64::from(d.days))?;
let local = add_local(zone.to_local(DateTime::from_timestamp(start, 0)?), days);
let exact = i64::from(d.hours) * 3600 + i64::from(d.minutes) * 60 + i64::from(d.seconds);
Some(zone.to_utc(local).timestamp() + exact)
}
@@ -1605,11 +1738,9 @@ impl Obj {
/// The latest THISANDFUTURE override at or before instance `key`, and
/// its key.
fn future(&self, key: i64) -> Option<(&Node, i64)> {
self.comps()
.filter(|c| is_range(&c.c))
.filter_map(|c| Some((c, self.key(&c.c)?)))
.filter(|(_, k)| *k <= key)
.max_by_key(|(_, k)| *k)
let ranges = &self.index().ranges;
let (k, at) = ranges[..ranges.partition_point(|(k, _)| *k <= key)].last()?;
Some((&self.root.children[*at], *k))
}
/// Where instance `key` starts once a THISANDFUTURE override moves it.
@@ -1701,9 +1832,9 @@ impl Obj {
{
set_param(e, ICalendarParameterName::Range, range);
}
self.root.children.push(n);
self.children_mut().push(n);
}
if let Some(e) = self.root.children[at]
if let Some(e) = self.children_mut()[at]
.c
.entries
.iter_mut()
▾Mpimdav/src/object.rs
@@ -3,7 +3,8 @@
use std::collections::HashSet;
use calcard::icalendar::{
ICalendar, ICalendarComponentType, ICalendarFrequency, ICalendarProperty, ICalendarValue,
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarFrequency, ICalendarProperty,
ICalendarValue,
};
use calcard::{Entry, Parser};
use chrono::{DateTime, Utc};
@@ -69,7 +70,7 @@ pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Inval
if too_deep(&cal) {
return Err(Invalid::CalendarData);
}
if too_many_rules(&cal) {
if too_costly(&cal) {
return Err(Invalid::CalendarResource);
}
let scheduled = |t: &ICalendarComponentType| {
@@ -167,20 +168,46 @@ fn too_deep(cal: &ICalendar) -> bool {
/// VTIMEZONEs can hold dozens of observances.
const MAX_RULES: usize = 4;
const MAX_ZONE_RULES: usize = 50;
const MAX_ZONES: usize = 50;
const MAX_ALL_ZONE_RULES: usize = 500;
/// A rule with COUNT expands from DTSTART on every query.
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;
/// Card views look up labels and groups across lines.
const MAX_CARD_LINES: usize = 10_000;
fn too_many_rules(cal: &ICalendar) -> bool {
let mut zone_rules = 0;
for c in &cal.components {
let rules: Vec<_> = c
.entries
fn too_costly(cal: &ICalendar) -> bool {
let rules = |c: &ICalendarComponent| {
c.entries
.iter()
.filter(|e| matches!(e.name, ICalendarProperty::Rrule | ICalendarProperty::Exrule))
.collect();
let costly = rules.iter().any(|e| match e.values.first() {
Some(ICalendarValue::RecurrenceRule(r)) => r.count.is_some_and(|n| {
.count()
};
let observance = |c: &&ICalendarComponent| {
matches!(
c.component_type,
ICalendarComponentType::Standard | ICalendarComponentType::Daylight
)
};
let zones = cal
.components
.iter()
.filter(|c| c.component_type == ICalendarComponentType::VTimezone)
.count();
let zone_rules: usize = cal.components.iter().filter(observance).map(rules).sum();
if cal.components.len() > MAX_COMPONENTS || zones > MAX_ZONES || zone_rules > MAX_ALL_ZONE_RULES
{
return true;
}
cal.components.iter().any(|c| {
let costly = c.entries.iter().any(|e| match (&e.name, e.values.first()) {
(
ICalendarProperty::Rrule | ICalendarProperty::Exrule,
Some(ICalendarValue::RecurrenceRule(r)),
) => r.count.is_some_and(|n| {
n > match r.freq {
ICalendarFrequency::Secondly
| ICalendarFrequency::Minutely
@@ -190,26 +217,33 @@ fn too_many_rules(cal: &ICalendar) -> bool {
}),
_ => false,
});
if costly {
return true;
}
let rules = rules.len();
match c.component_type {
ICalendarComponentType::Standard | ICalendarComponentType::Daylight => {
zone_rules += rules
let attendees = c
.entries
.iter()
.filter(|e| e.name == ICalendarProperty::Attendee)
.count();
costly
|| attendees > MAX_ATTENDEES
|| match c.component_type {
ICalendarComponentType::VTimezone => {
c.component_ids
.iter()
.filter_map(|&id| cal.components.get(id as usize))
.map(rules)
.sum::<usize>()
> MAX_ZONE_RULES
}
ICalendarComponentType::Standard | ICalendarComponentType::Daylight => false,
_ => rules(c) > MAX_RULES,
}
_ if rules > MAX_RULES => return true,
_ => {}
}
}
zone_rules > MAX_ZONE_RULES
})
}
/// Checks an address object resource (RFC 6352, 5.1) and returns its UID. A
/// card without one is accepted: several clients omit it.
pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
let text = std::str::from_utf8(body).map_err(|_| Invalid::AddressData)?;
if !ends_with(text, "END:VCARD") {
if !ends_with(text, "END:VCARD") || logical_lines(text).len() > MAX_CARD_LINES {
return Err(Invalid::AddressData);
}
let mut parser = Parser::new(text);
▾Mpimdav/src/render.rs
@@ -445,7 +445,9 @@ pub fn address_data(raw: &str, req: &AddressData) -> String {
}
/// calcard's 4.0 writer escapes the comma of a `data:` URI as TEXT, which
/// breaks the URI. Binary values have no backslash to keep.
/// breaks the URI. Binary values have no backslash to keep. A vCard 3 PHOTO
/// without TYPE becomes `data:;base64,`, so the media type comes from the
/// first bytes.
fn raw_data_uris(text: &str) -> String {
let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut out = String::with_capacity(text.len());
@@ -458,10 +460,29 @@ fn raw_data_uris(text: &str) -> String {
&& line[start..]
.get(..5)
.is_some_and(|v| v.eq_ignore_ascii_case("data:"));
match uri && line.contains("\\,") {
true => out.push_str(&crate::text::fold(&line.replace("\\,", ","), eol)),
false => out.push_str(raw),
if !uri {
out.push_str(raw);
continue;
}
let line = line.replace("\\,", ",");
let (head, value) = line.split_at(crate::text::value_start(&line));
const BARE: &str = "data:;base64,";
let data = value
.get(..BARE.len())
.filter(|p| p.eq_ignore_ascii_case(BARE))
.map(|_| &value[BARE.len()..]);
let sniffed = data.and_then(|d| match d {
d if d.starts_with("iVBORw0KGgo") => Some("image/png"),
d if d.starts_with("/9j/") => Some("image/jpeg"),
d if d.starts_with("R0lGOD") => Some("image/gif"),
d if d.starts_with("UklG") && d.get(12..16) == Some("RUJQ") => Some("image/webp"),
_ => None,
});
let line = match (sniffed, data) {
(Some(t), Some(d)) => format!("{head}data:{t};base64,{d}"),
_ => line.clone(),
};
out.push_str(&crate::text::fold(&line, eol));
}
out
}
▾Mpimdav/src/text.rs
@@ -36,6 +36,17 @@ pub(crate) fn name(line: &str) -> String {
line[..end].trim().to_ascii_uppercase()
}
/// The property name without its Apple group prefix.
pub(crate) fn prop(line: &str) -> String {
let n = name(line);
n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string())
}
/// The Apple group prefix of a property name, like `ITEM1`.
pub(crate) fn group(line: &str) -> Option<String> {
name(line).rsplit_once('.').map(|(g, _)| g.to_string())
}
/// Where the value of an unfolded line starts: after the first colon
/// outside a quoted parameter value.
pub(crate) fn value_start(line: &str) -> usize {
▾Mpimdav/src/view.rs
@@ -1,5 +1,7 @@
//! Plain summaries of calendar objects and contacts, for a user interface.
use std::collections::HashMap;
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
ICalendarProperty, ICalendarValue,
@@ -8,7 +10,7 @@ use chrono::{DateTime, TimeDelta, Utc};
use crate::expand::{Instance, expand};
use crate::itip::Is;
use crate::text::{logical_lines, name, param, param_parts, unescape_text, unfold, value};
use crate::text::{group, logical_lines, param, param_parts, prop, unescape_text, unfold, value};
use crate::zone::{Zone, Zones};
#[derive(Debug, Clone, PartialEq)]
@@ -262,24 +264,20 @@ pub struct Card {
/// What a vCard 3 or 4 says, Apple's group and label forms included.
pub fn card(vcard: &str) -> Card {
let lines: Vec<String> = logical_lines(vcard).into_iter().map(unfold).collect();
let prop = |l: &str| {
let n = name(l);
n.rsplit_once('.').map_or(n.clone(), |(_, n)| n.to_string())
};
let group = |l: &str| name(l).rsplit_once('.').map(|(g, _)| g.to_string());
// Apple names a value by a sibling X-ABLABEL line, e.g.
// `_$!<Mobile>!$_` or a free text.
// `_$!<Mobile>!$_` or a free text. The first label of a group counts.
let mut labels: HashMap<String, String> = HashMap::new();
for o in lines.iter().filter(|o| prop(o) == "X-ABLABEL") {
if let Some(g) = group(o) {
labels.entry(g).or_insert_with(|| {
let v = unescape_text(value(o));
let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_");
v.to_ascii_lowercase()
});
}
}
let label = |l: &str| {
let apple = group(l).and_then(|g| {
lines
.iter()
.find(|o| group(o).as_deref() == Some(g.as_str()) && prop(o) == "X-ABLABEL")
.map(|o| {
let v = unescape_text(value(o));
let v = v.trim_start_matches("_$!<").trim_end_matches(">!$_");
v.to_ascii_lowercase()
})
});
let apple = group(l).and_then(|g| labels.get(&g).cloned());
apple.or_else(|| {
let types: Vec<String> = param_parts(l)
.into_iter()
@@ -400,6 +398,12 @@ pub fn card(vcard: &str) -> Card {
.or_else(|| org.clone().filter(|o| !o.is_empty()))
.or_else(|| emails.first().map(|e| e.value.clone()))
.unwrap_or_default();
let [birthday, anniversary] = crate::contact::special_dates(&lines).map(|d| {
d.map(|(year, month, day)| match year {
Some(y) => format!("{y:04}-{month:02}-{day:02}"),
None => format!("--{month:02}-{day:02}"),
})
});
Card {
uid: first("UID").map(|u| u.strip_prefix("urn:uuid:").map(str::to_string).unwrap_or(u)),
full_name,
@@ -409,14 +413,8 @@ pub fn card(vcard: &str) -> Card {
phones: all("TEL", &tel),
addresses: all("ADR", &adr),
urls: all("URL", &plain),
birthday: lines
.iter()
.find(|l| prop(l) == "BDAY")
.and_then(|l| date(value(l))),
anniversary: lines
.iter()
.find(|l| prop(l) == "ANNIVERSARY" || prop(l) == "X-ANNIVERSARY")
.and_then(|l| date(value(l))),
birthday,
anniversary,
note: first("NOTE"),
is_group,
members,
@@ -424,23 +422,6 @@ pub fn card(vcard: &str) -> Card {
}
}
/// `1980-03-15`, or `--03-15` without a year, from the date forms vCard 3
/// and 4 use.
fn date(v: &str) -> Option<String> {
let v = v.trim().split(['T', 't']).next()?;
let digits = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_digit());
match v.strip_prefix("--") {
Some(md) => {
let md = md.replace('-', "");
digits(&md, 4).then(|| format!("--{}-{}", &md[..2], &md[2..]))
}
None => {
let d = v.replace('-', "");
digits(&d, 8).then(|| format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..]))
}
}
}
/// The parts of a structured value (N, ADR, ORG): split at `;` that are not
/// escaped, then unescaped.
fn split_structured(v: &str) -> Vec<String> {
▾Mpimdav/src/xml.rs
@@ -115,12 +115,14 @@ pub fn update(body: &[u8]) -> Result<Update, Invalid> {
for p in props {
let name = Name::of(p);
out.remove.retain(|n| *n != name);
out.set.retain(|q| Name::of(q) != 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.retain(|n| *n != name);
out.remove.push(name);
}
}
▾Mpimdav/tests/bundle.rs
@@ -381,6 +381,33 @@ fn overrides_of_several_masters_are_placed_quickly() {
);
}
#[test]
fn many_masters_of_one_uid_are_placed_quickly() {
let mut file = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n");
for n in 0..400 {
file.push_str(&format!(
"BEGIN:VEVENT\r\nUID:m\r\nDTSTART:2024{:02}01T100000Z\r\nRRULE:FREQ=DAILY;COUNT=2\r\nSUMMARY:S{n}\r\nEND:VEVENT\r\n",
n % 12 + 1
));
}
for n in 0..400 {
file.push_str(&format!(
"BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID:2024{:02}02T100000Z\r\nDTSTART:2024{:02}02T120000Z\r\nSUMMARY:O{n}\r\nEND:VEVENT\r\n",
n % 12 + 1,
n % 12 + 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() < 1.0,
"{:?}",
started.elapsed()
);
assert_eq!(parts.len(), 400);
}
#[test]
fn a_busy_range_override_of_a_free_series_stays_busy() {
let object = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
▾Mpimdav/tests/contact.rs
@@ -136,3 +136,32 @@ fn many_apple_dates_stay_linear() {
);
assert_eq!(dates.len(), 1);
}
#[test]
fn the_card_view_reads_apple_dates() {
let apple = card(
"BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\nitem1.X-ABDATE:2001-06-01\nitem1.X-ABLabel:_$!<Anniversary>!$_\n",
);
let view = pimdav::view::card(&apple);
assert_eq!(view.birthday.as_deref(), Some("--03-15"));
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"));
}
#[test]
fn many_labelled_emails_stay_linear() {
let mut lines = String::new();
for n in 0..5000 {
lines.push_str(&format!("item{n}.EMAIL:u{n}@x\nitem{n}.X-ABLabel:work\n"));
}
let started = std::time::Instant::now();
let view = pimdav::view::card(&card(&lines));
assert!(
started.elapsed().as_secs_f64() < 2.0,
"{:?}",
started.elapsed()
);
assert_eq!(view.emails.len(), 5000);
assert_eq!(view.emails[4999].label.as_deref(), Some("work"));
}
▾Mpimdav/tests/itip.rs
@@ -1313,3 +1313,144 @@ fn a_stale_range_past_the_series_cuts_nothing() {
assert!(bob.contains("COUNT=3"), "{bob}");
assert!(!bob.contains("UNTIL"), "{bob}");
}
#[test]
fn a_huge_duration_does_not_panic() {
let huge = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:20260105T100000\nDURATION:P99999999W\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\nEND:VEVENT\n"
));
itip::organize(Some(&huge), Some(huge.clone()), &is(ALICE), now());
let accepted = ICalendar::parse(text(&huge).replace("NEEDS-ACTION", "ACCEPTED")).unwrap();
assert!(itip::attend(&huge, accepted, &is(BOB), now()).is_ok());
}
#[test]
fn many_attendees_and_ranges_stay_fast() {
let people: String = (0..50)
.map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n"))
.collect();
let second = |n: i64| {
(Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::seconds(n))
.format("%Y%m%dT%H%M%SZ")
.to_string()
};
let ranges: String = (1..=20)
.map(|i| {
let at = second(i * 400);
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
DURATION:PT1M\nORGANIZER:{ALICE}\nATTENDEE:mailto:u0@example.com\nEND:VEVENT\n"
)
})
.collect();
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1M\nRRULE:FREQ=SECONDLY;COUNT=10000\n\
ORGANIZER:{ALICE}\n{people}END:VEVENT\n{ranges}",
second(0)
));
let t = std::time::Instant::now();
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
let u1 = text(&to(&msgs, "mailto:u1@example.com").unwrap().cal);
assert!(u1.contains("UNTIL=20260105T100639Z"), "{u1}");
}
#[test]
fn a_copy_with_many_overrides_is_answered_fast() {
let people =
format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n");
let day = |i: i64| {
(chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
.format("%Y%m%dT100000")
.to_string()
};
let overrides: String = (0..8000)
.map(|i| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;TZID=Europe/Berlin:{d}\n\
DTSTART;TZID=Europe/Berlin:{d}\nDURATION:PT1H\nSUMMARY:{i}\n{people}END:VEVENT\n",
d = day(i)
)
})
.collect();
let copy = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:{}\nDURATION:PT1H\n\
RRULE:FREQ=DAILY;COUNT=9000\n{people}END:VEVENT\n{overrides}",
day(0)
));
let answered = ICalendar::parse(text(&copy).replacen(
&format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
&format!("PARTSTAT=ACCEPTED:{BOB}"),
1,
))
.unwrap();
let t = std::time::Instant::now();
let (_, reply) = itip::attend(&copy, answered, &is(BOB), now()).unwrap();
assert!(reply.is_some());
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
}
#[test]
fn clients_may_name_the_week_start_or_confirm_their_instance() {
let people =
format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n");
let series = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260107T100000Z\nDTEND:20260107T110000Z\n\
RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=4\n{people}END:VEVENT\n"
);
let accepted = series.replace("NEEDS-ACTION", "ACCEPTED");
let with_wkst = accepted.replace("COUNT=4", "COUNT=4;WKST=SU");
assert!(itip::attend(&cal(&series), cal(&with_wkst), &is(BOB), now()).is_ok());
// Every other week depends on where weeks start.
let biweekly = series.replace("FREQ=WEEKLY", "FREQ=WEEKLY;INTERVAL=2");
let moved = biweekly
.replace("NEEDS-ACTION", "ACCEPTED")
.replace("COUNT=4", "COUNT=4;WKST=SU");
assert_eq!(
itip::attend(&cal(&biweekly), cal(&moved), &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
let confirmed = cal(&format!(
"{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260114T100000Z\nDTSTART:20260114T100000Z\n\
DTEND:20260114T110000Z\nSTATUS:CONFIRMED\n{}END:VEVENT\n",
people.replace("NEEDS-ACTION", "ACCEPTED")
));
assert!(itip::attend(&cal(&series), confirmed.clone(), &is(BOB), now()).is_ok());
// The organizer's CONFIRMED instance is not the attendee's to drop.
assert_eq!(
itip::attend(&confirmed, cal(&accepted), &is(BOB), now()).err(),
Some(Refused::AttendeeChange)
);
}
#[test]
fn a_sequence_bump_reaches_the_copies_quietly() {
let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
let (store, _) = itip::organize(None, Some(old), &is(ALICE), now());
let old = store.unwrap();
let bumped =
ICalendar::parse(text(&old).replace("SUMMARY:Sync", "SUMMARY:Sync\r\nSEQUENCE:3")).unwrap();
let (store, msgs) = itip::organize(Some(&old), Some(bumped), &is(ALICE), now());
let bob = to(&msgs, BOB).expect("no update for bob");
assert!(bob.quiet);
assert!(text(&bob.cal).contains("SEQUENCE:3"));
// bob's later answer carries that SEQUENCE and lands.
let copy = itip::receive(
None,
&Message {
quiet: false,
..bob.clone()
},
)
.unwrap();
let declined = ICalendar::parse(text(&copy).replace(
&format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
&format!("PARTSTAT=DECLINED:{BOB}"),
))
.unwrap();
let (_, reply) = itip::attend(&copy, declined, &is(BOB), now()).unwrap();
assert!(reply.is_some(), "{}", text(&copy));
let mut org = store.unwrap();
assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
}
▾Mpimdav/tests/protocol.rs
@@ -41,6 +41,15 @@ fn update_bodies() {
assert!(Name::of(&u.set[0]).is(APPLE, "calendar-color"));
assert_eq!(u.remove, vec![Name::new(DAV, "displayname")]);
// A repeated set keeps only the last value.
let body = br#"<d:propertyupdate xmlns:d="DAV:">
<d:set><d:prop><d:displayname>A</d:displayname></d:prop></d:set>
<d:set><d:prop><d:displayname>B</d:displayname></d:prop></d:set>
</d:propertyupdate>"#;
let u = xml::update(body).unwrap();
assert_eq!(u.set.len(), 1);
assert_eq!(xml::text(&u.set[0]), "B");
let body = br#"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:set><d:prop><c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set></d:prop></d:set>
</c:mkcalendar>"#;
@@ -249,6 +258,63 @@ fn too_many_rules_are_refused() {
object::calendar(&ics(&format!("{}{EVENT}", zone(51))), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
// The cap is per zone, not per object.
let zones = (0..3)
.map(|i| zone(50).replace("TZID:X", &format!("TZID:X{i}")))
.collect::<String>();
assert!(object::calendar(&ics(&format!("{zones}{EVENT}")), &["VEVENT"]).is_ok());
// But zones and their rules are capped in total.
let zones = |n: usize, rules: usize| {
(0..n)
.map(|i| zone(rules).replace("TZID:X", &format!("TZID:X{i}")))
.collect::<String>()
};
assert_eq!(
object::calendar(&ics(&format!("{}{EVENT}", zones(51, 0))), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
assert_eq!(
object::calendar(&ics(&format!("{}{EVENT}", zones(11, 50))), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
// An observance outside any zone counts too.
let stray = zone(501)
.replace("BEGIN:VTIMEZONE\r\nTZID:X\r\n", "")
.replace("END:VTIMEZONE\r\n", "");
assert_eq!(
object::calendar(&ics(&format!("{stray}{EVENT}")), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
}
#[test]
fn huge_objects_are_refused() {
let attendees = |n: usize| {
let lines: String = (0..n)
.map(|i| format!("ATTENDEE:mailto:u{i}@x\r\n"))
.collect();
EVENT.replace("END:VEVENT", &format!("{lines}END:VEVENT"))
};
assert!(object::calendar(&ics(&attendees(2000)), &["VEVENT"]).is_ok());
assert_eq!(
object::calendar(&ics(&attendees(2001)), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let alarm = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\n";
let alarms = EVENT.replace("END:VEVENT", &format!("{}END:VEVENT", alarm.repeat(4000)));
assert_eq!(
object::calendar(&ics(&alarms), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let card = |n: usize| {
let notes = "NOTE:x\r\n".repeat(n);
format!("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\n{notes}END:VCARD\r\n")
};
assert!(object::vcard(card(9000).as_bytes()).is_ok());
assert_eq!(
object::vcard(card(10_000).as_bytes()),
Err(Invalid::AddressData)
);
}
#[test]
▾Mpimdav/tests/report.rs
@@ -522,6 +522,46 @@ fn a_photo_data_uri_survives_the_conversion_to_4_0() {
assert!(!out.contains("\\,"), "{out}");
}
#[test]
fn a_photo_without_type_gets_its_media_type_in_4_0() {
let v4 = AddressData {
props: None,
version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
};
for (data, mime) in [
(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg==",
"image/png",
),
("/9j/4AAQSkZJRgABAQ==", "image/jpeg"),
(
"R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==",
"image/gif",
),
(
"UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==",
"image/webp",
),
] {
let card = format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p\r\nFN:Pic\r\nPHOTO;ENCODING=b:{data}\r\nEND:VCARD\r\n"
);
let out = address_data(&card, &v4).replace("\r\n ", "");
assert!(out.contains(&format!("data:{mime};base64,{data}")), "{out}");
}
// A stored 4.0 card in upper case is sniffed too.
let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:p\r\nFN:Pic\r\nPHOTO:DATA:;BASE64,/9j/4AAQSkZJRgABAQ==\r\nEND:VCARD\r\n";
let photo = AddressData {
props: Some(vec![pimdav::render::PropSelect {
name: "PHOTO".into(),
novalue: false,
}]),
..v4
};
let out = address_data(card, &photo).replace("\r\n ", "");
assert!(out.contains("data:image/jpeg;base64,/9j/"), "{out}");
}
#[test]
fn expand_keeps_a_task_without_start() {
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nDUE:20260105T100000Z\r\n\
@@ -619,3 +659,26 @@ fn vcard_three_gets_plain_numbers_and_apple_birthdays() {
let back = address_data(&v3, &to(V4_0));
assert!(back.contains("BDAY:--0315\r\n"), "{back}");
}
#[test]
fn an_instance_moved_by_a_range_takes_the_overrides_component() {
let cal = cal(concat!(
"BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20240101T000000Z\r\nDTSTART:20240101T100000Z\r\n",
"DURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Old\r\nEND:VEVENT\r\n",
"BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20240101T000000Z\r\n",
"RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\n",
"DURATION:PT1H\r\nSUMMARY:New\r\nEND:VEVENT\r\n",
));
let rid = Some(utc("2024-01-04T10:00:00"));
let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap();
// The instance belongs to the series, not to an override of its own.
assert!(
!cal.components[index]
.has_property(&pimdav::calcard::icalendar::ICalendarProperty::RecurrenceId)
);
let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap();
let summary = cal.components[i.component]
.property(&pimdav::calcard::icalendar::ICalendarProperty::Summary)
.and_then(|e| e.values.first()?.as_text().map(str::to_string));
assert_eq!(summary.as_deref(), Some("New"));
}
▾Mserver/src/api/pim.rs
@@ -304,7 +304,10 @@ fn allowed(target: &Target) -> &'static str {
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",
Target::Home(..) | Target::Principal(_) => {
"OPTIONS, GET, HEAD, PROPFIND, PROPPATCH, REPORT"
}
Target::Root | Target::Principals => "OPTIONS, GET, HEAD, PROPFIND, REPORT",
}
}
@@ -1481,8 +1484,20 @@ impl Cx<'_> {
return Ok(status(StatusCode::NOT_FOUND));
};
let href = self.space().collection(*kind, slug);
// Per property, so a client that colors every calendar it sees
// goes on.
if col.access != Access::Own {
return Ok(denied(&href, "write-properties"));
let mut r = xml::Response::new(href.clone());
r.error = Some(need_privilege(&href, DAV, "write-properties"));
let names = update
.set
.iter()
.map(Name::of)
.chain(update.remove.iter().cloned());
for n in names {
r.push(403, n.element());
}
return Ok(multistatus(&[r], None));
}
let place = PropPlace::Collection(col.c.id);
let stored = (*kind, col.c.clone());
@@ -1869,8 +1884,25 @@ fn apply(
patch
}
fn is_color(v: &str) -> bool {
matches!(v.len(), 7 | 9) && v.starts_with('#') && v[1..].bytes().all(|b| b.is_ascii_hexdigit())
/// `#RRGGBB` or `#RRGGBBAA`, with `#RGB` widened to `#RRGGBB`.
pub(super) fn color(v: &str) -> Option<String> {
let hex = v
.strip_prefix('#')
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))?;
match hex.len() {
3 => Some(format!(
"#{}",
hex.chars().flat_map(|c| [c, c]).collect::<String>()
)),
6 | 8 => Some(v.to_string()),
_ => None,
}
}
/// An integer order. Some clients write a fraction.
fn order(v: &str) -> Option<String> {
let n = v.parse::<f64>().ok().filter(|n| n.is_finite())?;
Some((n.round() as i64).to_string())
}
/// Sets one of a collection's own properties. `None` if it is none of them,
@@ -1912,22 +1944,20 @@ fn set_own(
}
valid
}
(APPLE, "calendar-color") if cal => {
let v = value();
let valid = v.as_deref().is_none_or(is_color);
if valid {
col.color = v;
(APPLE, "calendar-color") if cal => match value() {
None => {
col.color = None;
true
}
valid
}
(APPLE, "calendar-order") if cal => {
let v = value();
let valid = v.as_deref().is_none_or(|v| v.parse::<i64>().is_ok());
if valid {
col.sort_order = v;
Some(v) => color(&v).map(|c| col.color = Some(c)).is_some(),
},
(APPLE, "calendar-order") if cal => match value() {
None => {
col.sort_order = None;
true
}
valid
}
Some(v) => order(&v).map(|o| col.sort_order = Some(o)).is_some(),
},
(CALDAV, "calendar-timezone") if cal => {
let tz = value();
let valid = tz.as_deref().is_none_or(is_timezone);
@@ -2057,14 +2087,14 @@ impl Cx<'_> {
{
return Ok(status(StatusCode::NOT_FOUND));
}
let body = match head {
true => "",
false => "CalDAV and CardDAV collection. Open it with a calendar or contacts app.\n",
};
let text = "CalDAV and CardDAV collection. Open it with a calendar or contacts app.\n";
Ok((
StatusCode::OK,
[(CONTENT_TYPE, "text/plain; charset=utf-8")],
body,
[
(CONTENT_TYPE, "text/plain; charset=utf-8".to_string()),
(CONTENT_LENGTH, text.len().to_string()),
],
if head { "" } else { text },
)
.into_response())
}
@@ -2188,6 +2218,7 @@ impl Cx<'_> {
owner,
may_schedule: access >= Access::Schedule,
sent_by: (access != Access::Own).then(|| self.me.address.clone()),
quiet: false,
}
}
@@ -2855,7 +2886,9 @@ impl Cx<'_> {
el(CALDAV, "supported-calendar-component"),
));
}
let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
let overwrite = !headers
.get("overwrite")
.is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"F"));
let target = self.member(&to.c, &to_name).await?;
if target.is_none() && to_name.len() > MAX_SLUG {
return Ok(status(StatusCode::FORBIDDEN));
▾Mserver/src/api/pim_api.rs
@@ -41,8 +41,8 @@ use crate::api::dav::challenge;
use crate::api::files::disposition;
use crate::api::pim::{
BIRTHDAYS, BIRTHDAYS_SLUG, DIRECTORY, DIRECTORY_SLUG, INBOX, MAX_COLLECTIONS, MAX_DESCRIPTION,
MAX_DISPLAYNAME, MAX_RESOURCE_SIZE, OUTBOX, SHARED_PREFIX, collection_href, delete_own,
etag_of, generated, mailto, members_of, valid_text,
MAX_DISPLAYNAME, MAX_RESOURCE_SIZE, OUTBOX, SHARED_PREFIX, collection_href, color as hex_color,
delete_own, etag_of, generated, mailto, members_of, valid_text,
};
use crate::api::pim_schedule::{self, Directory, object_name};
use crate::api::pim_views;
@@ -182,11 +182,6 @@ fn db_kind(kind: PimCollectionKind) -> PimKind {
}
/// `#rgb`, `#rrggbb` or `#rrggbbaa`: what clients write to `calendar-color`.
fn valid_color(c: &str) -> bool {
c.strip_prefix('#')
.is_some_and(|h| [3, 6, 8].contains(&h.len()) && h.bytes().all(|b| b.is_ascii_hexdigit()))
}
fn bad_request(msg: &str) -> ApiError {
ApiError::new(StatusCode::BAD_REQUEST, msg)
}
@@ -217,10 +212,10 @@ pub async fn create(
auth: SessionUser,
Json(body): Json<CreatePimCollection>,
) -> Result<Json<PimCollectionInfo>, ApiError> {
let color = body.color.filter(|c| !c.trim().is_empty());
if color.as_deref().is_some_and(|c| !valid_color(c)) {
return Err(bad_request("invalid color"));
}
let color = match body.color.filter(|c| !c.trim().is_empty()) {
Some(c) => Some(hex_color(c.trim()).ok_or_else(|| bad_request("invalid color"))?),
None => None,
};
let info = create_collection(
&state,
&auth.user,
@@ -342,10 +337,10 @@ pub async fn update(
}
if let Some(color) = body.color {
let color = color.trim();
if !color.is_empty() && !valid_color(color) {
return Err(bad_request("invalid color"));
}
col.color = (!color.is_empty()).then(|| color.to_string());
col.color = match color.is_empty() {
true => None,
false => Some(hex_color(color).ok_or_else(|| bad_request("invalid color"))?),
};
}
if let Some(d) = body.description {
if !valid_text(&d, MAX_DESCRIPTION, true) {
@@ -677,18 +672,6 @@ pub async fn create_link(
AxumPath(id): AxumPath<i64>,
Json(body): Json<CreatePimLink>,
) -> Result<Json<PimLinkInfo>, ApiError> {
let id = own(&state, &auth, id).await?;
let (_, kind, _) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
if body.busy_only && kind != PimKind::Calendar {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"busy_only needs a calendar",
));
}
// As for shares: an unparseable expiry would never expire.
if let Some(e) = &body.expires_at {
match chrono::DateTime::parse_from_rfc3339(e) {
@@ -700,17 +683,15 @@ pub async fn create_link(
));
}
Ok(t) if t <= chrono::Utc::now() => {
return Err(bad_request("expires_at is in the past"));
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"expires_at is in the past",
"err_expires_in_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) {
Some(pw) if !pw.is_empty() => {
validate_password(pw)?;
@@ -718,6 +699,27 @@ pub async fn create_link(
}
_ => None,
};
// The count and the insert hold the lock, so the cap holds.
let _lock = pim_schedule::LOCK.lock().await;
let id = own(&state, &auth, id).await?;
let (_, kind, _) = state
.db
.pim_collection_by_id(id)
.await?
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
if body.busy_only && kind != PimKind::Calendar {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"busy_only needs a calendar",
));
}
let links = state.db.pim_links(id).await?;
if links.iter().filter(|l| !l.is_expired()).count() >= MAX_LINKS {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
format!("a collection has at most {MAX_LINKS} feeds"),
));
}
let link = state
.db
.pim_create_link(
@@ -949,8 +951,8 @@ pub async fn import_new(
.ok_or_else(|| bad_request("a name is required"))?;
// COLOR may be a CSS color name, which the web UI cannot show.
let color = own_color
.filter(|c| valid_color(c))
.or(q.color.filter(|c| valid_color(c)));
.and_then(|c| hex_color(&c))
.or(q.color.and_then(|c| hex_color(&c)));
let pid = state.db.principal_of(auth.user.id).await?;
state.db.pim_ensure_defaults(pid).await?;
let info = create_collection(&state, &auth.user, kind, &name, color, None, &[]).await?;
@@ -1020,12 +1022,18 @@ async fn import_parts(
parts: Vec<String>,
) -> Result<PimImportResult, ApiError> {
let supported: Vec<String> = col.components.split(',').map(str::to_string).collect();
let timezone = col.timezone.clone();
let now = chrono::Utc::now();
let checked = blocking(move || -> Result<_, ApiError> {
let supported: Vec<&str> = supported.iter().map(String::as_str).collect();
let now = chrono::Utc::now();
Ok(parts
.into_iter()
.map(|part| check_part(kind, &supported, now, part))
.map(|part| {
let part = check_part(kind, &supported, now, part)?;
let ended = kind == PimKind::Calendar
&& pim_schedule::ended(&part.2, timezone.as_deref(), now);
Ok((part, ended))
})
.collect::<Vec<_>>())
})
.await?;
@@ -1043,11 +1051,12 @@ async fn import_parts(
.get(owner)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "collection not found"))?;
let w = pim_schedule::Writer {
let mut w = pim_schedule::Writer {
owner: &owner,
may_schedule,
sent_by: (owner.id != me)
.then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
quiet: false,
};
let mut result = PimImportResult {
created: 0,
@@ -1069,7 +1078,7 @@ async fn import_parts(
let mut ops = Vec::new();
let (mut created, mut updated) = (0, 0);
for part in checked {
let (uid, component, data) = match part {
let ((uid, component, data), ended) = match part {
Ok(v) => v,
Err((uid, reason)) => {
skip(uid, &reason);
@@ -1091,6 +1100,20 @@ async fn import_parts(
Some(n) => state.db.pim_object(col.id, n).await?.map(|(_, d)| d),
None => None,
};
// Old exports would otherwise invite everyone to meetings long
// over. Without the right to schedule, a meeting stays refused.
w.quiet = may_schedule
&& ended
&& match &old {
Some(o) => {
let (o, tz) = (o.clone(), col.timezone.clone());
blocking(move || {
Ok::<_, ApiError>(pim_schedule::ended(&o, tz.as_deref(), now))
})
.await?
}
None => true,
};
let at = (col.id, name.as_str());
match pim_schedule::put(state, &dir, &w, at, old.as_deref(), &data).await? {
Ok(s) => s,
▾Mserver/src/api/pim_schedule.rs
@@ -13,7 +13,10 @@ use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use percent_encoding::percent_decode_str;
use pimdav::calcard::icalendar::{ICalendar, ICalendarComponentType};
use pimdav::calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarProperty, ICalendarValue,
};
use pimdav::expand;
use pimdav::filter::TimeRange;
use pimdav::freebusy::{self, Period};
use pimdav::itip::{self, Message, Method, Role};
@@ -61,6 +64,8 @@ pub(crate) struct Writer<'a> {
pub may_schedule: bool,
/// The writer's address when it is not the owner, for SENT-BY.
pub sent_by: Option<String>,
/// Stores the object without sending anything.
pub quiet: bool,
}
impl Writer<'_> {
@@ -70,6 +75,7 @@ impl Writer<'_> {
owner,
may_schedule: true,
sent_by: None,
quiet: false,
}
}
@@ -250,11 +256,18 @@ pub(crate) async fn put(
(Role::Organizer, _) => {
let old = old.as_ref().filter(|_| old_role == Some(Role::Organizer));
let (mut store, force) = itip::prepare(old, &sent, &owns);
let mut messages = itip::messages(old, Some(&store), &owns, &force, now);
if !messages.is_empty() {
if !w.may_schedule {
let mut messages = match w.quiet {
true => Vec::new(),
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) {
return Ok(Err(w.refused("schedule-send-invite")));
}
messages.clear();
}
if !messages.is_empty() {
itip::stamp_sender(&mut store, &owns, w.sent_by.as_deref());
messages = itip::messages(old, Some(&store), &owns, &force, now);
}
@@ -275,7 +288,7 @@ pub(crate) async fn put(
Ok(v) => v,
Err(refused) => return Ok(Err(refused.condition())),
};
if let Some(mut reply) = reply {
if let Some(mut reply) = reply.filter(|_| !w.quiet) {
if !w.may_schedule {
return Ok(Err(w.refused("schedule-send-reply")));
}
@@ -289,7 +302,7 @@ pub(crate) async fn put(
// No longer a scheduling object, or a copy the attendee brings in
// itself (RFC 6638, 3.2.2.2): stored as sent.
(_, previous) => {
if let Some(old) = &old {
if let Some(old) = old.as_ref().filter(|_| !w.quiet) {
match removed(state, dir, w, old, previous, true).await? {
Ok(more) => ops.extend(more),
Err(refused) => return Ok(Err(refused)),
@@ -315,6 +328,42 @@ pub(crate) async fn put(
}))
}
/// Whether `store` differs from `old` for the attendees only in SEQUENCE.
fn only_sequence(
old: Option<&ICalendar>,
store: &ICalendar,
owns: itip::Is,
force: &[String],
now: DateTime<Utc>,
) -> bool {
let Some(old) = old else {
return false;
};
let key = |c: &ICalendarComponent| {
c.property(&ICalendarProperty::RecurrenceId)
.map(|e| format!("{:?}", e.values))
};
let sequences: HashMap<_, _> = old
.components
.iter()
.filter(|c| c.has_property(&ICalendarProperty::Uid))
.map(|c| (key(c), c.property(&ICalendarProperty::Sequence).cloned()))
.collect();
let mut same = store.clone();
for c in same
.components
.iter_mut()
.filter(|c| c.has_property(&ICalendarProperty::Uid))
{
let Some(sequence) = sequences.get(&key(c)) else {
return false;
};
c.entries.retain(|e| e.name != ICalendarProperty::Sequence);
c.entries.extend(sequence.clone());
}
itip::messages(Some(old), Some(&same), owns, force, now).is_empty()
}
/// The resource name the server picks for an object it creates.
pub(crate) fn object_name(uid: &str, kind: PimKind) -> String {
let ext = match kind {
@@ -528,6 +577,36 @@ fn floating_of(timezone: Option<&str>) -> Zone {
timezone.and_then(zone::from_vtimezone).unwrap_or(Zone::Utc)
}
/// Whether every instance of the calendar object `body` ended before `now`.
/// A component without a start, or a rule without COUNT that runs until
/// about now or later, never ends.
pub(crate) fn ended(body: &[u8], timezone: Option<&str>, now: DateTime<Utc>) -> bool {
let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(body).as_ref()) else {
return false;
};
// A day of slack covers an UNTIL in a zone or floating.
let soon = now.naive_utc() - chrono::TimeDelta::days(1);
let open = cal.components.iter().any(|c| {
let item = matches!(
c.component_type,
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
);
let endless = c.properties(&ICalendarProperty::Rrule).any(|e| {
matches!(e.values.first(), Some(ICalendarValue::RecurrenceRule(r))
if r.count.is_none() && r.until.as_ref().and_then(|u| u.to_date_time())
.is_none_or(|u| u.date_time >= soon))
});
item && (endless || !c.has_property(&ICalendarProperty::Dtstart))
});
if open {
return false;
}
let x = expand::expand(&cal, now..DateTime::<Utc>::MAX_UTC, floating_of(timezone));
x.instances.is_empty() && !x.truncated
}
/// The answers to a free-busy request to an outbox (RFC 6638, 5.2): per
/// recipient its address, the REQUEST-STATUS and the VFREEBUSY reply.
pub(crate) async fn free_busy(
@@ -757,3 +836,25 @@ fn identity(cal: &ICalendar) -> Option<(String, String)> {
})?;
Some((c.uid()?.to_string(), c.component_type.as_str().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rule_running_on_has_not_ended_and_costs_nothing() {
let body = |rule: &str| {
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:x\r\n\
DTSTAMP:20200101T000000Z\r\nDTSTART:20200101T100000Z\r\n{rule}END:VEVENT\r\nEND:VCALENDAR\r\n"
)
};
let now = Utc::now();
let started = std::time::Instant::now();
let on = body("RRULE:FREQ=MINUTELY;UNTIL=99991231T000000Z\r\n");
assert!(!ended(on.as_bytes(), None, now));
assert!(started.elapsed() < std::time::Duration::from_millis(200));
let over = body("RRULE:FREQ=DAILY;UNTIL=20200110T000000Z\r\n");
assert!(ended(over.as_bytes(), None, now));
}
}
▾Mserver/src/api/pim_views.rs
@@ -230,8 +230,13 @@ pub async fn object(
let index = view::component_for(&cal, rid, &zone).ok_or_else(not_found)?;
let dir = Directory::load(&state).await?;
let owns = dir.is(owner);
let info = view::event_info(&cal, index, &owns);
let instance = view::instance_for(&cal, index, rid, &zone);
// An instance a THISANDFUTURE override moved takes its text too.
let info = view::event_info(
&cal,
instance.as_ref().map_or(index, |i| i.component),
&owns,
);
let answers = may_answer(&state, &auth, owner, col.id).await?;
let attendee = matches!(itip::role(&cal, &owns), Ok(Role::Attendee));
Ok(Json(PimObjectDetail::Event(PimEventDetail {
@@ -543,6 +548,7 @@ pub async fn reply(
may_schedule: true,
sent_by: (me != owner)
.then(|| format!("mailto:{}", mailto(&auth.user.name, UserType::Individual))),
quiet: false,
};
let stored = match pim_schedule::put(
&state,
▾Mserver/tests/api_pim.rs
@@ -1280,8 +1280,10 @@ async fn lent_collections() {
let r = req(&env, "DELETE", &lunch, &bob, &[], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let patch = r#"<d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>Mine</d:displayname></d:prop></d:set></d:propertyupdate>"#;
// Refused per property, so clients go on with the next calendar.
let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let ms = parse_multistatus(&r);
assert!(ms[0].1.iter().all(|(c, _)| *c == 403), "{}", r.text());
// Read-write: bob adds an event, alice sees it. Moving it into his own
// calendar is refused: an object never changes owner.
@@ -1310,8 +1312,10 @@ async fn lent_collections() {
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
let r = req(&env, "GET", &format!("{CAL}new.ics"), &alice_auth, &[], "").await;
assert_eq!(r.status, StatusCode::OK);
// Refused per property, so clients go on with the next calendar.
let r = req(&env, "PROPPATCH", &shared, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let ms = parse_multistatus(&r);
assert!(ms[0].1.iter().all(|(c, _)| *c == 403), "{}", r.text());
// bob deleting it only takes it out of his home.
let r = req(&env, "DELETE", &shared, &bob, &[], "").await;
@@ -1821,6 +1825,22 @@ async fn mkcol_checks_target_and_values() {
assert_eq!(r.status, StatusCode::FORBIDDEN);
let r = req(&env, "MKCALENDAR", work, &auth, &[], &body("#FF8800AA")).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
// A short color is widened, a fractional order rounded.
let home = "/pim/calendars/alice/home/";
let mk = r#"<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:set><d:prop><a:calendar-color>#f80</a:calendar-color><a:calendar-order>2.6</a:calendar-order></d:prop></d:set></c:mkcalendar>"#;
let r = req(&env, "MKCALENDAR", home, &auth, &[], mk).await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
let r = req(&env, "PROPFIND", home, &auth, &[("depth", "0")], "").await;
let ms = parse_multistatus(&r);
assert_eq!(
prop_text(&ms, home, APPLE, "calendar-color").as_deref(),
Some("#ff8800")
);
assert_eq!(
prop_text(&ms, home, APPLE, "calendar-order").as_deref(),
Some("3")
);
}
async fn alice_pid(env: &Env) -> i64 {
▾Mserver/tests/api_pim_clients.rs
@@ -286,8 +286,8 @@ async fn client_properties_are_stored() {
),
"#ff0000"
);
let r = req(&env, "PROPPATCH", &lent, &bob, &[], patch).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let (ps, _) = props(&req(&env, "PROPPATCH", &lent, &bob, &[], patch).await);
assert!(ps.iter().all(|(code, _)| *code == 403), "{ps:?}");
// And another account's home is not writable.
let r = req(&env, "PROPPATCH", home, &bob, &[], custom).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
▾Mserver/tests/api_pim_io.rs
@@ -281,14 +281,47 @@ async fn import_splits_and_updates() {
assert!(text.contains("DTSTAMP:"), "import adds DTSTAMP: {text}");
}
#[tokio::test]
async fn an_import_of_meetings_long_over_sends_nothing() {
let io = Io::new().await;
let cal = io.id(CAL).await;
let people =
"ORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE:mailto:bob@dovenest.invalid\r\n";
let r = io.import(cal, &event("old", "Old", people)).await;
assert_eq!(r["created"], 1, "{r}");
for path in ["/pim/calendars/bob/default/", "/pim/calendars/bob/inbox/"] {
let r = io.dav("bob", "PROPFIND", path, "").await;
assert!(!r.text().contains(".ics</"), "{path}: {}", r.text());
}
// Still a scheduling object, so later changes schedule as usual.
let listing = io.dav("alice", "PROPFIND", CAL, "").await.text();
let href = listing
.split("<d:href>")
.filter_map(|s| s.split("</d:href>").next())
.find(|h| h.ends_with(".ics"))
.unwrap_or_else(|| panic!("{listing}"))
.to_string();
let r = io.dav("alice", "GET", &href, "").await;
assert!(r.header("schedule-tag").is_some(), "{}", r.text());
let until = format!("RRULE:FREQ=DAILY;UNTIL=29991231T000000Z\r\n{people}");
let r = io.import(cal, &event("new", "New", &until)).await;
assert_eq!(r["created"], 1, "{r}");
let r = io
.dav("bob", "PROPFIND", "/pim/calendars/bob/inbox/", "")
.await;
assert!(r.text().contains(".ics</"), "{}", r.text());
}
#[tokio::test]
async fn import_schedules_like_a_put_and_keeps_uniqueness() {
let io = Io::new().await;
let cal = io.id(CAL).await;
// A yearly series has not ended, so it schedules.
let meeting = event(
"meet",
"Meeting",
"ORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE:mailto:bob@dovenest.invalid\r\n",
"RRULE:FREQ=YEARLY\r\nORGANIZER:mailto:alice@dovenest.invalid\r\nATTENDEE:mailto:bob@dovenest.invalid\r\n",
);
let r = io.import(cal, &meeting).await;
assert_eq!(r["created"], 1, "{r}");
▾Mserver/tests/api_pim_schedule.rs
@@ -1390,3 +1390,83 @@ async fn a_plain_event_with_the_same_uid_does_not_block_a_meeting() {
.await;
assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
}
#[tokio::test]
async fn a_quiet_import_keeps_the_attendees_answers() {
let pim = Pim::new().await;
let past = meeting("20240301T100000Z", &[&addr("bob")]);
pim.put_ok("alice", ALICE_EVENT, &past).await;
let (bob_href, _) = pim.copy("bob").await;
let r = pim.req("bob", "DELETE", &bob_href, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let declined = |org: &str| attendee_param(org, &addr("bob"), "PARTSTAT");
let org = pim.get("alice", ALICE_EVENT).await;
assert_eq!(declined(&org).as_deref(), Some("DECLINED"), "{org}");
let sent = pim.inbox("bob").await.len();
// An old export claims bob accepted. The meeting is over, so nothing is
// sent, but bob's real answer stays.
let alice = login(&pim.env, "alice", PW).await;
let listed = alice.get("/api/pim/collections").await.json();
let id = listed
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "calendar" && c["mode"].is_null())
.unwrap()["id"]
.as_i64()
.unwrap();
let export = past.replace("ATTENDEE;RSVP=TRUE:", "ATTENDEE;PARTSTAT=ACCEPTED:");
let r = alice
.raw(
Method::POST,
&format!("/api/pim/collections/{id}/import"),
&[],
export.into_bytes(),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(r.json()["updated"], 1, "{}", r.text());
let org = pim.get("alice", ALICE_EVENT).await;
assert_eq!(declined(&org).as_deref(), Some("DECLINED"), "{org}");
assert_eq!(pim.inbox("bob").await.len(), sent);
}
#[tokio::test]
async fn a_plain_loan_may_bump_the_sequence() {
let pim = Pim::new().await;
invite(&pim, &[&addr("carol")]).await;
let sent = pim.inbox("carol").await.len();
let alice = login(&pim.env, "alice", PW).await;
let listed = alice.get("/api/pim/collections").await.json();
let id = listed
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "calendar" && c["mode"].is_null())
.unwrap()["id"]
.as_i64()
.unwrap();
let r = alice
.post_json(
&format!("/api/pim/collections/{id}/shares"),
&json!({"user": "bob", "mode": "rw"}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
// bob's client adds an alarm and bumps SEQUENCE. That invites no one.
let edit = meeting("20260301T100000Z", &[&addr("carol")]).replace(
"END:VEVENT",
"SEQUENCE:1\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:x\r\n\
TRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT",
);
pim.put_ok(
"bob",
&format!("/pim/calendars/bob/shared-{id}/meet.ics"),
&edit,
)
.await;
assert!(pim.get("alice", ALICE_EVENT).await.contains("BEGIN:VALARM"));
assert_eq!(pim.inbox("carol").await.len(), sent);
}
▾Mserver/tests/api_pim_ui.rs
@@ -107,10 +107,12 @@ async fn collections_can_be_created_changed_and_deleted() {
.alice
.post_json(
"/api/pim/collections",
&json!({"kind": "calendar", "name": "Work & Play"}),
&json!({"kind": "calendar", "name": "Work & Play", "color": "#f80"}),
)
.await;
assert_eq!(again.json()["url"], "/pim/calendars/alice/work-play-2/");
// A short color is stored in the long form, as over CalDAV.
assert_eq!(again.json()["color"], "#ff8800");
// Reserved names and bad input.
let r = ui
.alice
@@ -699,6 +701,18 @@ async fn feeds_are_capped_and_never_born_expired() {
.post_json(&url, &json!({"expires_at": "2000-01-01T00:00:00Z"}))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text());
assert_eq!(r.json()["code"], "err_expires_in_past");
// Expired feeds do not count.
for i in 0..50 {
let token = format!("old{i}");
let past = Some("2000-01-01T00:00:00Z");
ui.env
.state
.db
.pim_create_link(id, &token, false, past, None)
.await
.unwrap();
}
for _ in 0..50 {
let r = ui.alice.post_json(&url, &json!({})).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
@@ -706,3 +720,33 @@ async fn feeds_are_capped_and_never_born_expired() {
let r = ui.alice.post_json(&url, &json!({})).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_instance_a_range_moved_shows_the_ranges_text() {
let ui = Ui::new().await;
ui.put(
"alice",
"/pim/calendars/alice/default/range.ics",
&event(
"range",
"DTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Old\r\n",
)
.replace(
"END:VCALENDAR",
"BEGIN:VEVENT\r\nUID:range\r\nDTSTAMP:20260101T000000Z\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260107T090000Z\r\nDTSTART:20260107T110000Z\r\nDURATION:PT1H\r\nSUMMARY:New\r\nEND:VEVENT\r\nEND:VCALENDAR",
),
)
.await;
let id = ui.id(&ui.alice, "/pim/calendars/alice/default/").await;
let r = ui
.alice
.get(&format!(
"/api/pim/collections/{id}/objects/range.ics?recurrence_id=2026-01-08T09:00:00Z"
))
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
let d = r.json();
assert_eq!(d["summary"], "New", "{d}");
assert_eq!(d["start"], "2026-01-08T11:00:00Z");
assert_eq!(d["is_override"], false);
}
▾Mweb/src/i18n.rs
@@ -274,6 +274,7 @@ i18n_keys! {
ERR_CHALLENGE_EXPIRED = "err_challenge_expired" => "That took too long, please try again.",
ERR_DEFAULT_CALENDAR = "err_default_calendar" => "The calendar that receives invitations cannot be deleted.",
ERR_DST_REQUIRED = "err_dst_required" => "dst_root_id is required",
ERR_EXPIRES_IN_PAST = "err_expires_in_past" => "The expiry date is in the past.",
ERR_FILE_NAME_REQUIRED = "err_file_name_required" => "a file name is required",
ERR_FILES_EXIST = "err_files_exist" => "some files already exist",
ERR_FOLDER_EXISTS = "err_folder_exists" => "a folder with this name already exists",
@@ -927,6 +928,10 @@ const DE: &[(&str, &str)] = &[
"Der Kalender, der Einladungen empfängt, kann nicht gelöscht werden.",
),
(k::ERR_DST_REQUIRED, "dst_root_id ist erforderlich"),
(
k::ERR_EXPIRES_IN_PAST,
"Das Ablaufdatum liegt in der Vergangenheit.",
),
(k::ERR_FILE_NAME_REQUIRED, "ein Dateiname ist erforderlich"),
(k::ERR_FILES_EXIST, "einige Dateien existieren bereits"),
(
@@ -1919,6 +1924,10 @@ const FR: &[(&str, &str)] = &[
"Le calendrier qui reçoit les invitations ne peut pas être supprimé.",
),
(k::ERR_DST_REQUIRED, "dst_root_id est requis"),
(
k::ERR_EXPIRES_IN_PAST,
"La date d'expiration est dans le passé.",
),
(k::ERR_FILE_NAME_REQUIRED, "un nom de fichier est requis"),
(k::ERR_FILES_EXIST, "certains fichiers existent déjà"),
(k::ERR_FOLDER_EXISTS, "un dossier avec ce nom existe déjà"),