CalDAV/CardDAV review fixes, round 11
- Replies and attendee overrides are checked per instance when the shared expansion is cut; a key that cannot be confirmed is refused or answered 5.1, never stored - Range overrides that leave an attendee out are checked in one expansion, with a full single-key fallback - Clients may spell out the implied BYDAY, BYMONTHDAY or BYMONTH of a rule; changes that show only in leap years are still caught; the old rule is read through the old object's zones - Values sent in base64 keep their encoding when a calendar or card is written again: ATTACH, X- binaries, PHOTO, LOGO, SOUND and KEY - A principal with two addresses gets one delivery of the same message - limit-recurrence-set skips the expansion when nothing is overridden - Tests for each change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mpimdav/src/itip.rs
@@ -19,7 +19,7 @@ use calcard::icalendar::{
use chrono::{DateTime, TimeDelta, Utc};
use xmltree::Element;
use crate::expand::{MAX_OCCURRENCES, expand, expand_in, stamp, until_utc};
use crate::expand::{Expansion, MAX_OCCURRENCES, expand, expand_in, stamp, until_utc};
use crate::filter::TimeRange;
use crate::freebusy::{Busy, Period, merge};
use crate::object::MAX_COMPONENTS;
@@ -509,7 +509,9 @@ pub fn attend(
if added.len() + excluded > MAX_ATTENDEE_CHANGES {
return Err(Refused::AttendeeChange);
}
let instances = old.instances(old_cal, &added);
// A key a cut-short expansion cannot confirm is refused, as the
// organizer's side would drop its reply.
let (instances, _) = old.instances(old_cal, &added);
for c in next.comps() {
let key = next.key(&c.c);
@@ -538,9 +540,8 @@ 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, true)
master.is_some() && instances.contains(&k) && old.plain(&next, &c.c, k, true)
}) {
return Err(Refused::AttendeeChange);
}
@@ -834,11 +835,20 @@ pub fn receive(copy: Option<&ICalendar>, msg: &Message) -> Option<ICalendar> {
}
}
/// The RECURRENCE-IDs an expansion gives.
fn rids(e: &Expansion) -> HashSet<i64> {
e.instances
.iter()
.filter_map(|i| Some(i.recurrence_id?.timestamp()))
.collect()
}
/// 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`.
/// Parts left out: the object would pass `MAX_COMPONENTS`, or a
/// cut-short expansion cannot confirm the instance.
pub dropped: usize,
}
@@ -856,8 +866,7 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> Appli
.filter_map(|c| rep.key(&c.c))
.filter(|k| next.position(Some(*k)).is_none())
.collect();
// A cut-short expansion over all parts falls back to one per part.
let shared = next.instances(org, &missing);
let (found, unknown) = 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 {
@@ -888,11 +897,11 @@ pub fn apply_reply(org: &mut ICalendar, reply: &ICalendar, replier: Is) -> Appli
else {
continue;
};
let known = match &shared {
Some(set) => set.contains(&t),
None => next.instances(org, &[t]).is_none_or(|set| set.contains(&t)),
};
if !known {
if unknown.contains(&t) {
dropped += 1;
continue;
}
if !found.contains(&t) {
continue;
}
let Some(inst) = next.single(at, &Zone::Utc) else {
@@ -1073,7 +1082,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(oc, new, nc)
moved || reinstated || exrule_changed || rules_grew(old, oc, new, nc)
}
/// A rule in one form: BY lists sorted, INTERVAL=1 as absent, and WKST
@@ -1117,7 +1126,7 @@ fn rules(c: &ICalendarComponent, prop: &ICalendarProperty) -> Vec<ICalendarRecur
v
}
fn rules_grew(oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bool {
fn rules_grew(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bool {
let rules = |c| rules(c, &ICalendarProperty::Rrule);
let (o, n) = (rules(oc), rules(nc));
if o == n {
@@ -1129,12 +1138,12 @@ fn rules_grew(oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bo
([o], [n]) => (o, n),
_ => return true,
};
if unbounded(o) != unbounded(n) {
if !same_starts(old, oc, o, new, nc, n) {
return true;
}
match (bounded(o), bounded(n)) {
(_, false) => true,
(false, true) => false,
(true, false) => true,
(false, _) => false,
(true, true) => new
.compare_ends(nc, o, n)
.is_none_or(|c| c == Ordering::Greater),
@@ -1142,8 +1151,8 @@ fn rules_grew(oc: &ICalendarComponent, new: &Obj, nc: &ICalendarComponent) -> bo
}
/// 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.
/// UNTIL, swap COUNT for UNTIL, or name the start's weekday, month or day,
/// 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),
@@ -1155,29 +1164,55 @@ fn same_rules(old: &Obj, oc: &ICalendarComponent, new: &Obj, nc: &ICalendarCompo
let ([o], [n]) = (&o[..], &n[..]) else {
return false;
};
bounded(o) == bounded(n)
&& same_starts(old, oc, o, new, nc, n)
&& (!bounded(o) || new.compare_ends(nc, o, n) == Some(Ordering::Equal))
}
/// Whether rules `o` of `oc` and `n` of `nc`, without their ends, give the
/// same starts.
fn same_starts(
old: &Obj,
oc: &ICalendarComponent,
o: &ICalendarRecurrenceRule,
new: &Obj,
nc: &ICalendarComponent,
n: &ICalendarRecurrenceRule,
) -> bool {
if unbounded(o) == unbounded(n) {
return true;
}
let loose = |r: &ICalendarRecurrenceRule| {
let mut r = unbounded(r);
if r.freq == ICalendarFrequency::Weekly {
r.byday.clear();
match r.freq {
ICalendarFrequency::Weekly => r.byday.clear(),
ICalendarFrequency::Monthly => r.bymonthday.clear(),
ICalendarFrequency::Yearly => {
r.bymonth.clear();
r.bymonthday.clear();
}
_ => {}
}
r
};
if loose(o) != loose(n) || bounded(o) != bounded(n) {
if loose(o) != loose(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)
// Weekdays and month days show within a year of periods. Leap days
// show within two leap cycles, also where BYMONTH leaves one month a year.
let yearly =
n.freq == ICalendarFrequency::Yearly || !o.bymonth.is_empty() || !n.bymonth.is_empty();
let days = match yearly {
true => 8 * 366,
false => 400,
} * i64::from(n.interval.unwrap_or(1).max(1));
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(days)))
};
let a = starts(old, oc, o);
a.is_some() && a == starts(new, nc, n)
}
/// `c` with `r` as its only RRULE.
@@ -1418,6 +1453,8 @@ struct Index {
ranges: Vec<(i64, usize)>,
/// Answers of `Obj::in_series`, which expands.
in_series: RefCell<HashMap<i64, bool>>,
/// The expansion for all ranges at once was cut short.
ranges_cut: Cell<bool>,
}
impl Obj {
@@ -1460,6 +1497,7 @@ impl Obj {
by_key,
ranges,
in_series: RefCell::default(),
ranges_cut: Cell::default(),
}
})
}
@@ -1680,28 +1718,45 @@ impl Obj {
/// 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))
let ix = self.index();
if let Some(&k) = ix.in_series.borrow().get(&key) {
return k;
}
// One expansion answers every range override at once.
let mut keys = vec![key];
if !ix.ranges_cut.get() {
keys.extend(ix.ranges.iter().map(|(k, _)| *k));
}
// Cut short, the batch falls back to the key alone with the full cap.
let cap = match keys.len() {
1 => MAX_OCCURRENCES,
_ => RULE_BUDGET,
};
let (mut found, cut) = self.expands_to(&keys, cap);
if cut && keys.len() > 1 {
ix.ranges_cut.set(true);
keys = vec![key];
found = self.expands_to(&keys, MAX_OCCURRENCES).0;
}
let mut memo = ix.in_series.borrow_mut();
for k in keys {
memo.insert(k, found.contains(&k));
}
memo[&key]
}
fn expands_to(&self, key: i64) -> bool {
let (Some(m), Some(at)) = (self.master(), DateTime::from_timestamp(key, 0)) else {
return false;
/// Which of `keys` the master's own rules reach, and whether the
/// expansion was cut short.
fn expands_to(&self, keys: &[i64], cap: usize) -> (HashSet<i64>, bool) {
let at = |t: Option<&i64>| DateTime::from_timestamp(*t?, 0);
let (Some(m), Some(from), Some(to)) =
(self.master(), at(keys.iter().min()), at(keys.iter().max()))
else {
return (HashSet::new(), false);
};
let window = at - TimeDelta::days(1)..at + TimeDelta::days(1);
expand_in(
&self.alone(m.c.clone()),
&self.zones,
window,
MAX_OCCURRENCES,
)
.instances
.iter()
.any(|i| i.recurrence_id == Some(at))
let window = from - TimeDelta::days(1)..to + TimeDelta::days(1);
let e = expand_in(&self.alone(m.c.clone()), &self.zones, window, cap);
(rids(&e), e.truncated)
}
/// The DTSTART of `c` as an instant.
@@ -1855,21 +1910,44 @@ impl Obj {
Some(zone.to_utc(local).timestamp() + exact)
}
/// Which of `keys` are instances of the series in `cal`, from one
/// expansion. `None` if the expansion was cut short and cannot tell.
fn instances(&self, cal: &ICalendar, keys: &[i64]) -> Option<HashSet<i64>> {
let starts: Vec<i64> = keys.iter().map(|k| self.moved(*k)).collect();
/// Which of `keys` are instances of the series in `cal`, and which a
/// cut-short expansion cannot tell.
fn instances(&self, cal: &ICalendar, keys: &[i64]) -> (HashSet<i64>, HashSet<i64>) {
let zones = Zones::new(cal, Zone::Utc);
let at = |t: i64| DateTime::from_timestamp(t, 0);
let starts: Vec<(i64, DateTime<Utc>)> = keys
.iter()
.filter_map(|k| Some((*k, at(self.moved(*k))?)))
.collect();
let (Some(lo), Some(hi)) = (
starts.iter().map(|(_, s)| *s).min(),
starts.iter().map(|(_, s)| *s).max(),
) else {
return Default::default();
};
// A day either side: an all-day shift is whole days, not 24 hours.
let from = at(*starts.iter().min()?)? - TimeDelta::days(1);
let to = at(*starts.iter().max()?)? + TimeDelta::days(1);
let e = expand(cal, from..to, Zone::Utc);
(!e.truncated).then(|| {
e.instances
.iter()
.filter_map(|i| Some(i.recurrence_id?.timestamp()))
.collect()
})
let day = TimeDelta::days(1);
// Cut short, the keys get checked one by one, so a small cap is safe.
let e = expand_in(cal, &zones, lo - day..hi + day, RULE_BUDGET);
if !e.truncated {
return (rids(&e), HashSet::new());
}
// The sender picks the keys and so the span.
let (mut found, mut unknown) = (HashSet::new(), HashSet::new());
let mut left = MAX_OCCURRENCES;
for (k, s) in starts {
let slack = match self.future(k) {
Some(_) => day,
None => TimeDelta::seconds(1),
};
let e = expand_in(cal, &zones, s - slack..s + slack, left);
left = left.saturating_sub(e.generated);
match e.truncated {
true => unknown.insert(k),
false => rids(&e).contains(&k) && found.insert(k),
};
}
(found, unknown)
}
/// Seconds from DTSTART to DTEND or DUE, or the DURATION. Without
Mpimdav/src/render.rs
@@ -4,13 +4,13 @@
use std::collections::{HashMap, HashSet};
use calcard::common::PartialDateTime;
use calcard::common::{Data, PartialDateTime};
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter,
ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, ICalendarValue,
ICalendarValueType,
};
use calcard::vcard::{VCard, VCardVersion};
use calcard::vcard::{VCard, VCardValue, VCardVersion};
use chrono::{DateTime, Utc};
use xmltree::Element;
@@ -153,7 +153,7 @@ pub fn calendar_data(
if *req == CalendarData::default() {
return Ok((raw.to_string(), 0));
}
let Ok(mut cal) = ICalendar::parse(raw) else {
let Some(mut cal) = parse(raw) else {
return Ok((raw.to_string(), 0));
};
let mut instances = 0;
@@ -300,6 +300,13 @@ fn in_utc(entries: &mut [ICalendarEntry], zones: &Zones) {
/// The masters, and only the overrides that affect `range` (RFC 4791,
/// 9.6.6).
fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICalendar {
if !cal
.components
.iter()
.any(|c| c.has_property(&ICalendarProperty::RecurrenceId))
{
return cal.clone();
}
let exp = expand(cal, range.clone(), floating.clone());
let used: HashSet<usize> = exp.instances.iter().map(|x| x.component).collect();
let zones = Zones::new(cal, floating.clone());
@@ -442,7 +449,7 @@ pub fn address_data(raw: &str, req: &AddressData) -> String {
if *req == AddressData::default() {
return raw.to_string();
}
let Ok(card) = VCard::parse(raw) else {
let Some(card) = parse_card(raw) else {
return raw.to_string();
};
let version = req.version.or(card.version()).unwrap_or_default();
@@ -451,7 +458,7 @@ pub fn address_data(raw: &str, req: &AddressData) -> String {
}
// calcard's 4.0 writer drops a `pref` type, so map before parsing.
let card = match version {
VCardVersion::V4_0 => VCard::parse(apple_forms(raw, version)).unwrap_or(card),
VCardVersion::V4_0 => parse_card(&apple_forms(raw, version)).unwrap_or(card),
_ => card,
};
let entries = match &req.props {
@@ -493,48 +500,48 @@ const URI_PROPS: [&str; 10] = [
];
/// calcard's writers escape `,` and `;` in URI values as TEXT, which breaks
/// the URI. A URI has no backslash to keep. A vCard 3 PHOTO without TYPE
/// becomes `data:;base64,`, so the media type comes from the first bytes.
/// the URI. A URI has no backslash to keep. A vCard PHOTO or KEY without a
/// media type becomes `data:;base64,`, so the type comes from the first
/// bytes or from `TYPE=PGP`.
fn raw_uris(text: &str) -> String {
let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut out = String::with_capacity(text.len());
for raw in crate::text::logical_lines(text) {
let line = crate::text::unfold(raw);
let start = crate::text::value_start(&line);
let name = crate::text::name(&line);
let name = name.rsplit('.').next().unwrap_or_default();
let uri = URI_PROPS.iter().any(|p| p.eq_ignore_ascii_case(name))
|| crate::text::param(&line, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri"))
|| ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name)
&& line[start..]
let orig = crate::text::unfold(raw);
let name = crate::text::prop(&orig);
let value = crate::text::value(&orig);
let uri = URI_PROPS.iter().any(|p| p.eq_ignore_ascii_case(&name))
|| crate::text::param(&orig, "VALUE").is_some_and(|v| v.eq_ignore_ascii_case("uri"))
|| ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str())
&& value
.get(..5)
.is_some_and(|v| v.eq_ignore_ascii_case("data:"));
if !uri {
out.push_str(raw);
continue;
}
let mut new = format!(
"{}{}",
&line[..start],
line[start..].replace("\\,", ",").replace("\\;", ";")
);
let (head, value) = new.split_at(start);
let head = crate::text::head(&orig);
let value = value.replace("\\,", ",").replace("\\;", ";");
const BARE: &str = "data:;base64,";
let data = value
.get(..BARE.len())
.filter(|p| p.eq_ignore_ascii_case(BARE))
.map(|_| &value[BARE.len()..]);
let pgp = name == "KEY"
&& crate::text::param(&orig, "TYPE").is_some_and(|t| t.eq_ignore_ascii_case("PGP"));
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"),
_ if pgp => Some("application/pgp-keys"),
_ => None,
});
if let (Some(t), Some(d)) = (sniffed, data) {
new = format!("{head}data:{t};base64,{d}");
}
if new == line {
let new = match (sniffed, data) {
(Some(t), Some(d)) => format!("{head}:data:{t};base64,{d}"),
_ => format!("{head}:{value}"),
};
if new == orig {
out.push_str(raw);
} else {
out.push_str(&crate::text::fold(&new, eol));
@@ -543,6 +550,96 @@ fn raw_uris(text: &str) -> String {
out
}
/// A calendar object parsed so that writing it keeps its binary values.
/// calcard reads a base64 value whose bytes are UTF-8 as TEXT and drops
/// its ENCODING.
pub fn parse(text: &str) -> Option<ICalendar> {
let mut cal = ICalendar::parse(text).ok()?;
let encoded = encoded(text);
if encoded.is_empty() {
return Some(cal);
}
for (i, c) in cal.components.iter_mut().enumerate() {
for e in &mut c.entries {
if let [ICalendarValue::Text(s)] = e.values.as_slice()
&& encoded.contains(&(i, e.name.as_str().to_ascii_uppercase(), base64(s)))
{
e.values = vec![ICalendarValue::Binary(s.clone().into_bytes())];
}
}
}
Some(cal)
}
/// [`parse`] for a vCard. Only media and keys are binary; a base64 NOTE
/// stays text.
fn parse_card(text: &str) -> Option<VCard> {
let mut card = VCard::parse(text).ok()?;
let encoded = encoded(text);
if encoded.is_empty() {
return Some(card);
}
for e in &mut card.entries {
let name = e.name.as_str().to_ascii_uppercase();
if ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name.as_str())
&& let [VCardValue::Text(s)] = e.values.as_slice()
&& encoded.contains(&(0, name, base64(s)))
{
e.values = vec![VCardValue::Binary(Data {
content_type: None,
data: s.clone().into_bytes(),
})];
}
}
Some(card)
}
/// The lines in base64, the encoding calcard drops, by the position of
/// their component in BEGIN order, name and value. With a CHARSET the
/// value is text.
fn encoded(text: &str) -> HashSet<(usize, String, String)> {
let mut out = HashSet::new();
if !text
.as_bytes()
.windows(9)
.any(|w| w.eq_ignore_ascii_case(b"ENCODING="))
{
return out;
}
let (mut open, mut next) = (Vec::new(), 0);
for l in crate::text::logical_lines(text)
.into_iter()
.map(crate::text::unfold)
{
match crate::text::prop(&l).as_str() {
"BEGIN" => {
open.push(next);
next += 1;
}
"END" => {
open.pop();
}
name if crate::text::param(&l, "ENCODING").is_some_and(|e| {
e.eq_ignore_ascii_case("BASE64") || e.eq_ignore_ascii_case("B")
}) && crate::text::param(&l, "CHARSET").is_none() =>
{
let value = crate::text::value(&l).split_whitespace().collect();
out.insert((open.last().copied().unwrap_or(0), name.to_string(), value));
}
_ => {}
}
}
out
}
fn base64(s: &str) -> String {
let data = Data {
content_type: None,
data: s.as_bytes().to_vec(),
};
data.to_unwrapped_string()["data:;base64,".len()..].to_string()
}
/// The version a GET asks for in its `Accept` header: 4.0 when named,
/// else 3.0, which RFC 6352 makes the default and Apple Contacts needs.
pub fn accepted_version(accept: Option<&str>) -> VCardVersion {
Mpimdav/tests/itip.rs
@@ -1769,3 +1769,251 @@ fn far_rule_changes_on_many_components_share_one_budget() {
assert!(itip::attend(©, answered, &is(BOB), now()).is_err());
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
}
#[test]
fn many_far_apart_replies_on_an_endless_dense_series_stay_fast() {
let series = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1S\n\
RRULE:FREQ=SECONDLY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
);
let parts: String = (1..=200)
.map(|d| {
let rid = (Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap()
+ chrono::TimeDelta::days(d))
.format("%Y%m%dT%H%M%SZ");
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1S\n\
ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
)
})
.collect();
let mut org = cal(&series);
let t = std::time::Instant::now();
let applied = itip::apply_reply(&mut org, &cal(&parts), &is(BOB));
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
// Each far key costs two days of seconds to check, so the budget ends
// early; the rest are reported, not taken unchecked.
let landed = text(&org).matches("PARTSTAT=DECLINED").count();
assert!(applied.changed && landed > 0 && applied.dropped > 0);
assert_eq!(landed + applied.dropped, 200);
}
#[test]
fn a_reply_may_not_add_an_override_of_no_instance_by_a_far_key() {
let series = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1H\nRRULE:FREQ=DAILY\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
);
let part = |rid: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1H\n\
ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
)
};
let reply = format!("{}{}", part("20260107T153000Z"), part("24000105T100000Z"));
let mut org = cal(&series);
itip::apply_reply(&mut org, &cal(&reply), &is(BOB));
let out = text(&org);
assert!(!out.contains("RECURRENCE-ID:20260107T153000Z"), "{out}");
assert!(out.contains("RECURRENCE-ID:24000105T100000Z"), "{out}");
}
#[test]
fn many_ranges_one_attendee_is_left_out_of_stay_fast() {
let day = |n: i64| {
(Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::days(n))
.format("%Y%m%dT%H%M%SZ")
.to_string()
};
let ranges: String = (1..=1000)
.map(|i| {
let at = day(i * 10);
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
DURATION:PT1H\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
)
})
.collect();
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1H\nRRULE:FREQ=DAILY;COUNT=100000\n\
ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n{ranges}",
day(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 carol = text(&to(&msgs, CAROL).unwrap().cal);
assert!(carol.contains("UNTIL=20260115T095959Z"), "{carol}");
}
#[test]
fn clients_may_spell_out_the_month_and_day_of_a_rule() {
let copy = |rule: &str| {
text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")))
.replace("FREQ=WEEKLY;COUNT=4", rule)
};
let answer = |old_rule: &str, new_rule: &str| {
itip::attend(
&ICalendar::parse(copy(old_rule)).unwrap(),
ICalendar::parse(copy(new_rule).replace("ACCEPTED:mailto:bob", "DECLINED:mailto:bob"))
.unwrap(),
&is(BOB),
now(),
)
.map(|_| ())
};
assert_eq!(
answer(
"FREQ=YEARLY;COUNT=3",
"FREQ=YEARLY;COUNT=3;BYMONTH=1;BYMONTHDAY=5"
),
Ok(())
);
assert_eq!(
answer("FREQ=MONTHLY;COUNT=3", "FREQ=MONTHLY;COUNT=3;BYMONTHDAY=5"),
Ok(())
);
assert_eq!(
answer("FREQ=MONTHLY;COUNT=3", "FREQ=MONTHLY;COUNT=3;BYMONTHDAY=6"),
Err(Refused::AttendeeChange)
);
let (store, _) = itip::organize(
Some(&ICalendar::parse(copy("FREQ=YEARLY")).unwrap()),
Some(ICalendar::parse(copy("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=5")).unwrap()),
&is(ALICE),
now(),
);
let store = text(&store.unwrap());
assert!(
store.contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
"{store}"
);
}
#[test]
fn a_spelled_out_rule_that_adds_leap_days_is_a_change() {
let copy = |rule: &str, stat: &str| {
text(&meeting(&format!("ATTENDEE;PARTSTAT={stat}:{BOB}\n")))
.replace("DTSTART:20260105T100000Z", "DTSTART:20260228T100000Z")
.replace("DTEND:20260105T110000Z", "DTEND:20260228T110000Z")
.replace("FREQ=WEEKLY;COUNT=4", rule)
};
let (old, new) = ("FREQ=YEARLY", "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=28,29");
let answer = itip::attend(
&ICalendar::parse(copy(old, "ACCEPTED")).unwrap(),
ICalendar::parse(copy(new, "DECLINED")).unwrap(),
&is(BOB),
now(),
);
assert_eq!(answer.map(|_| ()), Err(Refused::AttendeeChange));
let (store, _) = itip::organize(
Some(&ICalendar::parse(copy(old, "ACCEPTED")).unwrap()),
Some(ICalendar::parse(copy(new, "ACCEPTED")).unwrap()),
&is(ALICE),
now(),
);
let store = text(&store.unwrap());
assert!(
store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
"{store}"
);
}
#[test]
fn far_ranges_on_an_endless_dense_series_stay_fast() {
let day = |n: i64| {
(Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::days(n))
.format("%Y%m%dT%H%M%SZ")
.to_string()
};
let ranges: String = (1..=60)
.map(|i| {
let at = day(i * 30);
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
DURATION:PT1M\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
)
})
.collect();
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1M\nRRULE:FREQ=MINUTELY\n\
ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n{ranges}",
day(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());
assert!(to(&msgs, CAROL).is_some());
}
#[test]
fn a_series_left_out_of_two_far_ranges_ends_at_the_first() {
let range = |at: &str| {
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
DURATION:PT1S\nORGANIZER:{ALICE}\nATTENDEE:{CAROL}\nEND:VEVENT\n"
)
};
// One expansion over both ranges is cut, and so is a small one per key:
// two days of seconds.
for (a, b) in [
("20260601T100000Z", "20260901T100000Z"),
("20260901T100000Z", "20260601T100000Z"),
] {
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260101T100000Z\nDURATION:PT1S\n\
RRULE:FREQ=SECONDLY\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n\
END:VEVENT\n{}{}",
range(a),
range(b)
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let bob = text(&to(&msgs, BOB).expect("bob is invited").cal);
assert!(bob.contains("UNTIL=20260601T095"), "{bob}");
}
}
#[test]
fn a_month_rule_that_adds_leap_days_is_a_change() {
let copy = |rule: &str, stat: &str| {
text(&meeting(&format!("ATTENDEE;PARTSTAT={stat}:{BOB}\n")))
.replace("DTSTART:20260105T100000Z", "DTSTART:20260129T100000Z")
.replace("DTEND:20260105T110000Z", "DTEND:20260129T110000Z")
.replace("FREQ=WEEKLY;COUNT=4", rule)
};
let (old, new) = (
"FREQ=MONTHLY;BYMONTH=2",
"FREQ=MONTHLY;BYMONTH=2;BYMONTHDAY=30",
);
let answer = itip::attend(
&ICalendar::parse(copy(old, "ACCEPTED")).unwrap(),
ICalendar::parse(copy(new, "DECLINED")).unwrap(),
&is(BOB),
now(),
);
assert_eq!(answer.map(|_| ()), Err(Refused::AttendeeChange));
}
#[test]
fn an_attendee_may_not_answer_an_instance_no_one_can_confirm() {
let series = format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1S\n\
RRULE:FREQ=SECONDLY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
);
// Each key costs two days of seconds to check, so the budget ends
// before the tenth.
let overrides: String = (10..20)
.map(|d| {
let rid = format!("203001{d}T100000Z");
format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1S\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\n\
END:VEVENT\n"
)
})
.collect();
let copy = format!("{series}{overrides}");
let answer = itip::attend(&cal(&series), cal(©), &is(BOB), now());
assert_eq!(answer.map(|_| ()), Err(Refused::AttendeeChange));
}
Mpimdav/tests/report.rs
@@ -813,3 +813,153 @@ fn card_uris_and_geo_survive_conversion() {
let flat = address_data(&high, &to(V3_0)).replace("\r\n ", "");
assert!(flat.contains("GEO:52.5;13.4\r\n"), "{flat}");
}
#[test]
fn binary_values_that_are_text_keep_their_encoding() {
use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n\
BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\n\
ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:SGVsbG8gd29ybGQKbGluZTI=\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let out = pimdav::render::write(&pimdav::render::parse(body).unwrap()).replace("\r\n ", "");
assert!(
out.contains(
"ATTACH;ENCODING=BASE64;FMTTYPE=text/plain;VALUE=BINARY:SGVsbG8gd29ybGQKbGluZTI=\r\n"
),
"{out}"
);
// "-----BEGIN PGP-----\nabc,d;e\n"
let key = "LS0tLS1CRUdJTiBQR1AtLS0tLQphYmMsZDtlCg==";
let v3 = format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:g\r\nFN:A\r\nKEY;TYPE=PGP;ENCODING=b:{key}\r\n\
PHOTO;VALUE=uri:http://x/p.jpg\r\nEND:VCARD\r\n"
);
let to = |v, props| AddressData {
props,
version: Some(v),
};
let v4 = address_data(&v3, &to(V4_0, None)).replace("\r\n ", "");
assert!(
v4.contains(&format!(":data:application/pgp-keys;base64,{key}\r\n")),
"{v4}"
);
assert!(v4.contains(":http://x/p.jpg\r\n"), "{v4}");
let keys = Some(vec![pimdav::render::PropSelect {
name: "KEY".into(),
novalue: false,
}]);
let only = address_data(&v3, &to(V3_0, keys)).replace("\r\n ", "");
assert!(only.contains(&format!(";ENCODING=b:{key}\r\n")), "{only}");
}
#[test]
fn only_values_sent_in_base64_are_written_in_base64() {
use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
let event = |attach: &str| {
let body = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nBEGIN:VEVENT\r\nUID:u\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\n{attach}\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n"
);
pimdav::render::write(&pimdav::render::parse(&body).unwrap()).replace("\r\n ", "")
};
let raw = event("ATTACH;VALUE=BINARY;FMTTYPE=image/png:iVBORw0KGgo=");
assert!(
raw.contains(":iVBORw0KGgo=\r\n") && !raw.contains("ENCODING"),
"{raw}"
);
// "a" CR LF "b"
let crlf = event("ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:YQ0KYg==");
assert!(crlf.contains(":YQ0KYg==\r\n"), "{crlf}");
let card = |version: &str, line: &str, to: pimdav::calcard::vcard::VCardVersion| {
let text =
format!("BEGIN:VCARD\r\nVERSION:{version}\r\nUID:g\r\nFN:A\r\n{line}\r\nEND:VCARD\r\n");
let req = AddressData {
props: Some(
["PHOTO", "KEY"]
.map(|name| pimdav::render::PropSelect {
name: name.into(),
novalue: false,
})
.to_vec(),
),
version: Some(to),
};
address_data(&text, &req).replace("\r\n ", "")
};
for (version, line, to, want) in [
("3.0", "PHOTO;TYPE=JPEG:/9j/4AAQ", V3_0, ":/9j/4AAQ\r\n"),
(
"4.0",
"PHOTO:www.example.com/photo.jpg",
V4_0,
":www.example.com/photo.jpg\r\n",
),
(
"4.0",
"PHOTO:www.example.com/photo.jpg",
V3_0,
":www.example.com/photo.jpg\r\n",
),
(
"3.0",
"KEY;TYPE=PGP:-----BEGIN PGP-----",
V3_0,
":-----BEGIN PGP-----\r\n",
),
] {
let out = card(version, line, to);
assert!(
out.contains(want) && !out.contains("ENCODING") && !out.contains("data:"),
"{out}"
);
}
// "k" CR LF "l", and bytes that look like a URI
for (line, want) in [
(
"KEY;TYPE=PGP;ENCODING=b:aw0KbA==",
";ENCODING=b:aw0KbA==\r\n",
),
(
"PHOTO;ENCODING=b:aHR0cDovL3gveQ==",
";ENCODING=b:aHR0cDovL3gveQ==\r\n",
),
("PHOTO;VALUE=BINARY;ENCODING=b:SGVsbG8=", ":SGVsbG8=\r\n"),
] {
let out = card("3.0", line, V3_0);
assert!(
out.contains(want) && out.contains("ENCODING=b") && !out.contains("BASE64"),
"{out}"
);
}
let v4 = card("3.0", "KEY;TYPE=PGP;ENCODING=b:aw0KbA==", V4_0);
assert!(
v4.contains(":data:application/pgp-keys;base64,aw0KbA==\r\n"),
"{v4}"
);
// Only media and keys are binary: a base64 NOTE stays text.
let note = address_data(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:g\r\nFN:A\r\nNOTE;ENCODING=b:SGVsbG8=\r\nEND:VCARD\r\n",
&AddressData {
props: None,
version: Some(V4_0),
},
);
assert!(note.contains("NOTE:Hello\r\n"), "{note}");
// A plain value in another component stays plain, though it equals the
// decoded bytes of the first.
let two = pimdav::render::write(
&pimdav::render::parse(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nBEGIN:VEVENT\r\nUID:u\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\n\
X-A;ENCODING=BASE64:aGk=\r\nEND:VEVENT\r\nBEGIN:VTODO\r\nUID:t\r\n\
DTSTAMP:20260101T000000Z\r\nX-A:hi\r\nEND:VTODO\r\nEND:VCALENDAR\r\n",
)
.unwrap(),
);
assert!(
two.contains(":aGk=\r\n") && two.contains("X-A:hi\r\n"),
"{two}"
);
}
Mserver/src/api/pim_schedule.rs
@@ -10,6 +10,7 @@
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use percent_encoding::percent_decode_str;
@@ -235,7 +236,7 @@ pub(crate) async fn put(
old: Option<&[u8]>,
body: &[u8],
) -> Result<Result<Stored, Element>, ApiError> {
let parse = |b: &[u8]| ICalendar::parse(String::from_utf8_lossy(b).as_ref()).ok();
let parse = |b: &[u8]| render::parse(&String::from_utf8_lossy(b));
let Some(sent) = parse(body) else {
return Ok(Ok(unchanged(body, None)));
};
@@ -280,8 +281,9 @@ pub(crate) async fn put(
if answer_rooms(state, dir, owner, &mut store, &messages, &mut ops, now).await? {
messages = itip::messages(old, Some(&store), &owns, &force, now);
}
let mut sent = Sent::new();
for m in &messages {
if let Some(status) = deliver(state, dir, owner, m, &mut ops).await? {
if let Some(status) = deliver(state, dir, owner, m, &mut ops, &mut sent).await? {
itip::set_attendee_status(&mut store, &m.to, status);
}
}
@@ -298,11 +300,7 @@ 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(
std::sync::Arc::make_mut(&mut reply.cal),
&owns,
w.sent_by.as_deref(),
);
itip::stamp_sender(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);
}
@@ -399,7 +397,7 @@ pub(crate) async fn delete(
old: &[u8],
reply: bool,
) -> Result<Result<Vec<PimOp>, Element>, ApiError> {
let Ok(old) = ICalendar::parse(String::from_utf8_lossy(old).as_ref()) else {
let Some(old) = render::parse(&String::from_utf8_lossy(old)) else {
return Ok(Ok(Vec::new()));
};
let role = itip::role(&old, &dir.is(w.owner.id)).ok();
@@ -448,8 +446,9 @@ async fn removed(
if !messages.is_empty() && !w.may_schedule {
return Ok(Err(w.refused("schedule-send-invite")));
}
let mut sent = Sent::new();
for m in &messages {
deliver(state, dir, owner, m, &mut ops).await?;
deliver(state, dir, owner, m, &mut ops, &mut sent).await?;
}
}
Some(Role::Attendee) if reply => {
@@ -647,14 +646,19 @@ pub(crate) async fn free_busy(
Ok(out)
}
/// Statuses of the deliveries in one batch: principal, body, quiet.
type Sent = Vec<(i64, Arc<ICalendar>, bool, Option<&'static str>)>;
/// A REQUEST or CANCEL from `sender` into the recipient's calendar and
/// inbox. Returns the delivery status, `None` for the sender itself.
/// inbox. Returns the delivery status, `None` for the sender itself. A
/// principal named by two addresses that see the same message gets it once.
async fn deliver(
state: &AppState,
dir: &Directory,
sender: &PimPrincipal,
m: &Message,
ops: &mut Vec<PimOp>,
sent: &mut Sent,
) -> Result<Option<&'static str>, ApiError> {
let p = match dir.resolve(&m.to) {
Recipient::Local(p) if p.id == sender.id => return Ok(None),
@@ -662,6 +666,26 @@ async fn deliver(
Recipient::Unknown => return Ok(Some(INVALID_USER)),
Recipient::External => return Ok(Some(NO_ROUTE)),
};
// A forced send must not reuse a quiet one.
if let Some((.., status)) = sent
.iter()
.find(|(id, c, quiet, _)| *id == p.id && Arc::ptr_eq(c, &m.cal) && *quiet == m.quiet)
{
return Ok(*status);
}
let status = deliver_to(state, dir, sender, p, m, ops).await?;
sent.push((p.id, m.cal.clone(), m.quiet, status));
Ok(status)
}
async fn deliver_to(
state: &AppState,
dir: &Directory,
sender: &PimPrincipal,
p: &PimPrincipal,
m: &Message,
ops: &mut Vec<PimOp>,
) -> Result<Option<&'static str>, ApiError> {
ensure(state, p).await?;
let Some((uid, component)) = identity(&m.cal) else {
return Ok(Some(REFUSED));
@@ -735,7 +759,7 @@ async fn reply_to(
let Some((collection_id, obj, data)) = state.db.pim_find_uid(organizer.id, &uid).await? else {
return Ok(NO_ROUTE);
};
let Ok(before) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
let Some(before) = render::parse(&String::from_utf8_lossy(&data)) else {
return Ok(NO_ROUTE);
};
// A UID alone proves nothing: only the organizer's own object takes it.
@@ -763,10 +787,11 @@ async fn reply_to(
});
// The others learn the new answer without a new Schedule-Tag.
let organizes = dir.is(organizer.id);
let mut sent = Sent::new();
for mut other in itip::messages(Some(&before), Some(&after), &organizes, &[], Utc::now()) {
if other.method == Method::Request && !replier(&other.to) {
other.quiet = true;
deliver(state, dir, organizer, &other, ops).await?;
deliver(state, dir, organizer, &other, ops, &mut sent).await?;
}
}
}
@@ -796,12 +821,10 @@ async fn copy_of(
let Some((id, obj, data)) = state.db.pim_find_uid(p.id, uid).await? else {
return Ok(Ok(None));
};
Ok(
match ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
Ok(c) if attends(dir, &c, p, organizer) => Ok(Some((id, obj, c))),
_ => Err(()),
},
)
Ok(match render::parse(&String::from_utf8_lossy(&data)) {
Some(c) if attends(dir, &c, p, organizer) => Ok(Some((id, obj, c))),
_ => Err(()),
})
}
async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
Mserver/src/api/pim_views.rs
@@ -94,7 +94,7 @@ async fn readable(
}
fn parse(data: &[u8]) -> Option<ICalendar> {
ICalendar::parse(String::from_utf8_lossy(data).as_ref()).ok()
render::parse(&String::from_utf8_lossy(data))
}
fn display(p: &Person) -> String {
Mserver/tests/api/pim_schedule.rs
@@ -1553,7 +1553,9 @@ async fn a_put_may_not_change_the_uid_of_an_object() {
#[tokio::test]
async fn uris_reach_copies_and_inboxes_unescaped() {
let pim = Pim::new().await;
let uris = "X-GOOGLE-CONFERENCE:https://meet/a,b\r\nURL:http://x/a,b;c\r\n";
// The attachment's bytes are UTF-8, which calcard reads as TEXT.
let uris = "X-GOOGLE-CONFERENCE:https://meet/a,b\r\nURL:http://x/a,b;c\r\n\
ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:SGVsbG8gd29ybGQKbGluZTI=\r\n";
let body = meeting("20260301T100000Z", &[&addr("bob")]).replace(
"SUMMARY:Planning\r\n",
&format!("SUMMARY:Planning\r\n{uris}"),
@@ -1568,5 +1570,18 @@ async fn uris_reach_copies_and_inboxes_unescaped() {
text.contains("https://meet/a,b") && text.contains("http://x/a,b;c"),
"{text}"
);
assert!(text.contains(";ENCODING=BASE64"), "{text}");
assert!(text.contains(":SGVsbG8gd29ybGQKbGluZTI=\r\n"), "{text}");
}
}
#[tokio::test]
async fn two_addresses_of_one_attendee_get_one_delivery() {
let pim = Pim::new().await;
let body = meeting("20260301T100000Z", &[&addr("bob"), "/pim/principals/bob/"]);
let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &body).await;
assert!(r.status.is_success(), "{}", r.text());
assert_eq!(pim.inbox("bob").await.len(), 1);
let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
assert_eq!(org.matches("SCHEDULE-STATUS=1.2").count(), 2, "{org}");
}