CalDAV/CardDAV review fixes, round 10
- Rule ends compare UNTIL in the wall time of the start, so date and floating UNTIL values near midnight UTC are judged right; a DATE UNTIL ends its day when a range cuts the series - Rule comparisons reuse the object's zones and share one budget per operation; zone rules that never match no longer run to year 9999 - URI values keep their commas and semicolons in every written calendar: REPORT output, stored copies, inbox messages and rewritten organizer objects; vCard output too - A PUT may not change the UID of an existing object; a card may gain a UID only when it had none - A room organizer gets its inbox with the first reply instead of a 500 - Invalid dated birthdays keep their day without the year; GEO converts between vCard 3 and 4; time-range filters group instances once - Tests for each change Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mpimdav/src/contact.rs
@@ -99,12 +99,11 @@ fn date(line: &str) -> Option<(Option<i32>, u32, u32)> {
let (month, day) = (md[..2].parse().ok()?, md[2..].parse().ok()?);
// 2000 is a leap year, so February 29 passes.
NaiveDate::from_ymd_opt(2000, month, day)?;
// A date its year does not have, like 1900-02-29, keeps its day.
let year = year
.filter(|y| ![0, 1604].contains(y))
.filter(|_| param(line, "X-APPLE-OMIT-YEAR").is_none());
if let Some(y) = year {
NaiveDate::from_ymd_opt(y, month, day)?;
}
.filter(|_| param(line, "X-APPLE-OMIT-YEAR").is_none())
.filter(|&y| NaiveDate::from_ymd_opt(y, month, day).is_some());
Some((year, month, day))
}
Mpimdav/src/expand.rs
@@ -18,7 +18,7 @@ use crate::zone::{Zone, Zones, add, add_local};
/// expansion gives up.
// ponytail: COUNT, MONTHLY and YEARLY rules iterate from DTSTART, so a long
// running one can hit this. Skip ahead for them if that matters.
const MAX_OCCURRENCES: usize = 1_000_000;
pub(crate) const MAX_OCCURRENCES: usize = 1_000_000;
#[derive(Debug, Clone, PartialEq)]
pub struct Instance {
@@ -37,6 +37,8 @@ pub struct Expansion {
pub instances: Vec<Instance>,
/// A series reached `MAX_OCCURRENCES`, so later instances are missing.
pub truncated: bool,
/// Occurrences the rules produced, also those outside the window.
pub generated: usize,
}
/// The instances of all VEVENT, VTODO and VJOURNAL components that overlap
@@ -46,7 +48,16 @@ pub struct Expansion {
/// Components without DTSTART are skipped. `floating` interprets values
/// without a zone.
pub fn expand(cal: &ICalendar, window: Range<DateTime<Utc>>, floating: Zone) -> Expansion {
let zones = Zones::new(cal, floating);
expand_in(cal, &Zones::new(cal, floating), window, MAX_OCCURRENCES)
}
/// `expand` with the zones built, and at most `cap` occurrences per series.
pub(crate) fn expand_in(
cal: &ICalendar,
zones: &Zones,
window: Range<DateTime<Utc>>,
cap: usize,
) -> Expansion {
let mut out = Expansion::default();
let mut groups: HashMap<(&ICalendarComponentType, &str), Vec<usize>> = HashMap::new();
for (i, c) in cal.components.iter().enumerate() {
@@ -60,11 +71,11 @@ pub fn expand(cal: &ICalendar, window: Range<DateTime<Utc>>, floating: Zone) ->
}
match c.uid() {
Some(uid) => groups.entry((&c.component_type, uid)).or_default().push(i),
None => expand_group(cal, &zones, &[i], &window, &mut out),
None => expand_group(cal, zones, &[i], &window, cap, &mut out),
}
}
for group in groups.values() {
expand_group(cal, &zones, group, &window, &mut out);
expand_group(cal, zones, group, &window, cap, &mut out);
}
out.instances.sort_by_key(|i| (i.start, i.component));
out
@@ -213,6 +224,7 @@ fn expand_group(
zones: &Zones,
group: &[usize],
window: &Range<DateTime<Utc>>,
max: usize,
out: &mut Expansion,
) {
let comp = |i: usize| &cal.components[i];
@@ -363,7 +375,7 @@ fn expand_group(
let (from, to) = (add(window.start, -slack), add(window.end, slack));
let (from_local, to_local) = (mz.to_local(from), mz.to_local(to));
let mut budget = MAX_OCCURRENCES;
let mut budget = max;
let mut excluded = HashSet::new();
for e in mc.properties(&ICalendarProperty::Exrule) {
let Some(rule) = e.values.first().and_then(rule) else {
@@ -519,6 +531,7 @@ fn occurrences_capped(
out.truncated = true;
}
*budget -= list.len();
out.generated += list.len();
// DTSTART is the first of COUNT (RFC 5545, 3.3.10), also when the rule
// skips it.
if let Some(n) = rule.count.filter(|_| until.is_none()).map(|n| n as usize)
@@ -580,6 +593,11 @@ fn until_local(u: &PartialDateTime, start: &Stamp) -> Option<NaiveDateTime> {
})
}
/// The last instant a rule with DTSTART `start` and UNTIL `u` starts at.
pub(crate) fn until_utc(u: &PartialDateTime, start: &Stamp) -> Option<DateTime<Utc>> {
Some(start.zone.to_utc(until_local(u, start)?))
}
fn day_key(local: NaiveDateTime) -> i64 {
local.date().and_time(NaiveTime::MIN).and_utc().timestamp()
}
Mpimdav/src/filter.rs
@@ -16,7 +16,7 @@ use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
use unicode_normalization::UnicodeNormalization;
use xmltree::Element;
use crate::expand::{Expansion, expand, stamp};
use crate::expand::{Instance, expand, stamp};
use crate::freebusy::periods;
use crate::itip::endless;
use crate::report::Refused;
@@ -309,7 +309,13 @@ struct Ctx<'a> {
/// expansion.
alarm_reach: TimeDelta,
/// One expansion per window, not one per component.
expanded: RefCell<HashMap<TimeRange, Rc<Expansion>>>,
expanded: RefCell<HashMap<TimeRange, Rc<Window>>>,
}
/// The instances of one expansion by component.
struct Window {
of: HashMap<usize, Vec<Instance>>,
truncated: bool,
}
/// The repetitions of alarm `a` and the time between them.
@@ -348,11 +354,21 @@ fn relative_reach(a: &ICalendarComponent) -> Option<TimeDelta> {
}
impl Ctx<'_> {
fn expand(&self, window: TimeRange) -> Rc<Expansion> {
fn expand(&self, window: TimeRange) -> Rc<Window> {
self.expanded
.borrow_mut()
.entry(window.clone())
.or_insert_with(|| Rc::new(expand(self.cal, window, self.floating.clone())))
.or_insert_with(|| {
let exp = expand(self.cal, window, self.floating.clone());
let mut of: HashMap<usize, Vec<Instance>> = HashMap::new();
for x in exp.instances {
of.entry(x.component).or_default().push(x);
}
Rc::new(Window {
of,
truncated: exp.truncated,
})
})
.clone()
}
@@ -419,7 +435,7 @@ impl Ctx<'_> {
if exp.truncated {
return true;
}
exp.instances.iter().filter(|x| x.component == i).any(|x| {
exp.of.get(&i).into_iter().flatten().any(|x| {
let (s, e) = (x.start, x.end);
match (todo, due, duration) {
(true, _, true) => r.start <= e && (r.end > s || r.end >= e),
@@ -521,9 +537,10 @@ impl Ctx<'_> {
if exp.truncated {
return true;
}
exp.instances
.iter()
.filter(|x| x.component == parent)
exp.of
.get(&parent)
.into_iter()
.flatten()
.any(|x| hit(add(if from_end { x.end } else { x.start }, offset)))
}
_ => false,
Mpimdav/src/itip.rs
@@ -4,7 +4,7 @@
//! Calendar user addresses are compared through closures, so the caller maps
//! them onto its principals.
use std::cell::{OnceCell, RefCell};
use std::cell::{Cell, OnceCell, RefCell};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -19,7 +19,7 @@ use calcard::icalendar::{
use chrono::{DateTime, TimeDelta, Utc};
use xmltree::Element;
use crate::expand::expand;
use crate::expand::{MAX_OCCURRENCES, expand, expand_in, stamp, until_utc};
use crate::filter::TimeRange;
use crate::freebusy::{Busy, Period, merge};
use crate::object::MAX_COMPONENTS;
@@ -1403,8 +1403,13 @@ struct Obj {
root: Node,
zones: Zones,
index: OnceCell<Index>,
/// Occurrences left for comparing rules. Spent, a rule change counts as
/// growth for the organizer and is refused from an attendee.
budget: Cell<usize>,
}
const RULE_BUDGET: usize = 100_000;
/// Lookups over `Obj::root`, built on first use.
struct Index {
/// The first scheduled component with each key.
@@ -1432,6 +1437,7 @@ impl Obj {
root,
zones: Zones::new(cal, Zone::Utc),
index: OnceCell::new(),
budget: Cell::new(RULE_BUDGET),
}
}
@@ -1687,10 +1693,15 @@ impl Obj {
return false;
};
let window = at - TimeDelta::days(1)..at + TimeDelta::days(1);
expand(&self.alone(m.c.clone()), window, Zone::Utc)
.instances
.iter()
.any(|i| i.recurrence_id == Some(at))
expand_in(
&self.alone(m.c.clone()),
&self.zones,
window,
MAX_OCCURRENCES,
)
.instances
.iter()
.any(|i| i.recurrence_id == Some(at))
}
/// The DTSTART of `c` as an instant.
@@ -1714,10 +1725,13 @@ impl Obj {
| ICalendarProperty::RecurrenceId
)
});
let e = expand(&self.alone(c), window, Zone::Utc);
let left = self.budget.get();
let e = expand_in(&self.alone(c), &self.zones, window, left);
if e.truncated {
self.budget.set(0);
return None;
}
self.budget.set(left.saturating_sub(e.generated));
let mut v: Vec<i64> = e
.instances
.iter()
@@ -1736,10 +1750,13 @@ impl Obj {
o: &ICalendarRecurrenceRule,
n: &ICalendarRecurrenceRule,
) -> Option<Ordering> {
// A DATE UNTIL includes its whole day.
let end = |u: &PartialDateTime| {
Some(self.at(u, None)? + if u.hour.is_none() { 86399 } else { 0 })
};
let dtstart = c.property(&ICalendarProperty::Dtstart)?;
let s = stamp(
&self.zones,
dtstart.values.first()?.as_partial_date_time()?,
dtstart.tz_id(),
)?;
let end = |u: &PartialDateTime| Some(until_utc(u, &s)?.timestamp());
let start = self.start(c)?;
// The last start COUNT keeps and the one after it.
let counted = |k: u32| {
@@ -1787,22 +1804,15 @@ impl Obj {
}
}
/// A calendar of `c` alone, with this object's time zones.
/// A calendar of `c` alone, to expand with this object's zones.
fn alone(&self, c: ICalendarComponent) -> ICalendar {
let mut root = Node {
let root = Node {
c: self.root.c.clone(),
children: self
.root
.children
.iter()
.filter(|n| n.c.component_type == ICalendarComponentType::VTimezone)
.cloned()
.collect(),
children: vec![Node {
c,
children: Vec::new(),
}],
};
root.children.push(Node {
c,
children: Vec::new(),
});
let mut components = Vec::new();
flatten(&root, &mut components);
ICalendar { components }
@@ -1892,6 +1902,7 @@ impl Obj {
let Some(form) = start.values.first().and_then(|v| v.as_partial_date_time()) else {
return;
};
let s = stamp(&self.zones, form, start.tz_id());
// Obj reads floating times in UTC, so `at` is their wall-clock time.
let until = if form.hour.is_none() {
PartialDateTime::from_date_timestamp(at - 86400)
@@ -1908,8 +1919,8 @@ impl Obj {
&& !r
.until
.as_ref()
.and_then(|u| self.at(u, start.tz_id()))
.is_some_and(|u| u < at)
.and_then(|u| until_utc(u, s.as_ref()?))
.is_some_and(|u| u.timestamp() < at)
{
r.until = Some(until.clone());
r.count = None;
Mpimdav/src/render.rs
@@ -171,7 +171,12 @@ pub fn calendar_data(
copy(&cal, 0, Some(sel), &mut out);
cal = ICalendar { components: out };
}
Ok((cal.to_string(), instances))
Ok((write(&cal), instances))
}
/// The text of `cal`. Use this instead of `to_string`, which breaks URIs.
pub fn write(cal: &ICalendar) -> String {
raw_uris(&cal.to_string())
}
/// One component per instance in `range`, in UTC, with a RECURRENCE-ID and
@@ -467,17 +472,30 @@ pub fn address_data(raw: &str, req: &AddressData) -> String {
};
let mut out = String::new();
let _ = VCard { entries }.write_to(&mut out, version);
match version {
VCardVersion::V4_0 => raw_data_uris(&out),
raw_uris(&match version {
VCardVersion::V4_0 => out,
_ => apple_forms(&out, version),
}
})
}
/// 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. 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 {
/// Properties whose values are URIs without a `VALUE=URI` parameter.
const URI_PROPS: [&str; 10] = [
"URL",
"GEO",
"ATTACH",
"CONFERENCE",
"TZURL",
"SOURCE",
"IMAGE",
"X-GOOGLE-CONFERENCE",
"X-MICROSOFT-SKYPETEAMSMEETINGURL",
"X-SOCIALPROFILE",
];
/// 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.
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) {
@@ -485,16 +503,22 @@ fn raw_data_uris(text: &str) -> String {
let start = crate::text::value_start(&line);
let name = crate::text::name(&line);
let name = name.rsplit('.').next().unwrap_or_default();
let uri = ["PHOTO", "LOGO", "SOUND", "KEY"].contains(&name)
&& line[start..]
.get(..5)
.is_some_and(|v| v.eq_ignore_ascii_case("data:"));
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..]
.get(..5)
.is_some_and(|v| v.eq_ignore_ascii_case("data:"));
if !uri {
out.push_str(raw);
continue;
}
let line = line.replace("\\,", ",");
let (head, value) = line.split_at(crate::text::value_start(&line));
let mut new = format!(
"{}{}",
&line[..start],
line[start..].replace("\\,", ",").replace("\\;", ";")
);
let (head, value) = new.split_at(start);
const BARE: &str = "data:;base64,";
let data = value
.get(..BARE.len())
@@ -507,11 +531,14 @@ fn raw_data_uris(text: &str) -> String {
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));
if let (Some(t), Some(d)) = (sniffed, data) {
new = format!("{head}data:{t};base64,{d}");
}
if new == line {
out.push_str(raw);
} else {
out.push_str(&crate::text::fold(&new, eol));
}
}
out
}
@@ -598,8 +625,8 @@ fn apple_forms(text: &str, version: VCardVersion) -> String {
}
/// vCard 4.0 values vCard 3.0 readers reject, and back: a `tel:` URI as the
/// bare number, and a birthday without year as Apple writes it, with
/// `X-APPLE-OMIT-YEAR`.
/// bare number, a `geo:` URI as `lat;lon`, and a birthday without year as
/// Apple writes it, with `X-APPLE-OMIT-YEAR`.
fn plain_forms(
v3: bool,
line: &str,
@@ -635,6 +662,23 @@ fn plain_forms(
_ => (params, value.to_string()),
}
}
(true, "GEO") => match value.trim().get(..4) {
Some(g) if g.eq_ignore_ascii_case("geo:") => {
let point = value.trim()[4..].split(';').next().unwrap_or_default();
let point = point.replace("\\,", ",");
let mut coords = point.split(',');
let (lat, lon) = (
coords.next().unwrap_or_default(),
coords.next().unwrap_or_default(),
);
(params, format!("{lat};{lon}"))
}
_ => (params, value.to_string()),
},
(false, "GEO") if value.contains(';') && !value.contains(':') => (
params,
format!("geo:{}", value.trim().replacen(';', ",", 1)),
),
(false, "BDAY") => {
let year = param("X-APPLE-OMIT-YEAR");
let date = value
Mpimdav/src/zone.rs
@@ -271,7 +271,16 @@ fn transitions(cal: &ICalendar, tz: &ICalendarComponent) -> Option<Transitions>
None => u.date_time,
})
});
onsets.extend(crate::expand::occurrences(rule, start, until, horizon).take(MAX_ONSETS));
onsets.extend(
crate::expand::occurrences(
rule,
start + SHIFT,
until.map(|u| u + SHIFT),
horizon + SHIFT,
)
.take(MAX_ONSETS)
.map(|t| t - SHIFT),
);
}
for e in obs.properties(&ICalendarProperty::Rdate) {
onsets.extend(
@@ -293,6 +302,11 @@ fn transitions(cal: &ICalendar, tz: &ICalendarComponent) -> Option<Transitions>
})
}
/// rrule runs a rule that never matches on to year 9999. The calendar and the
/// weekdays repeat every 400 years (146,097 days), so zone rules run this much
/// later, close to that limit.
const SHIFT: TimeDelta = TimeDelta::days(19 * 146_097);
fn offset(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<i32> {
let v = c.property(prop)?.values.first()?.as_partial_date_time()?;
let secs = i32::from(v.tz_hour?) * 3600 + i32::from(v.tz_minute.unwrap_or(0)) * 60;
Mpimdav/tests/contact.rs
@@ -75,7 +75,9 @@ fn birthday_forms() {
);
}
assert!(contact::dates(&card("BDAY;VALUE=text:circa 1800\n"), "k").is_empty());
assert!(contact::dates(&card("BDAY:1981-02-29\n"), "k").is_empty());
let (_, ics) = &contact::dates(&card("BDAY:1900-02-29\n"), "k")[0];
assert!(ics.contains("SUMMARY:🎂 Anna Berg\r\n"));
assert!(ics.contains("DTSTART;VALUE=DATE:19720229"));
}
#[test]
Mpimdav/tests/itip.rs
@@ -1663,3 +1663,109 @@ fn moving_a_far_until_costs_only_the_gap() {
assert!(attended.is_ok(), "{:?}", attended.err());
assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed());
}
#[test]
fn rule_ends_compare_in_the_wall_time_of_the_start() {
let series = |start: &str, rule: &str, bob: &str| {
cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART;TZID={start}\nDURATION:PT1H\nRRULE:{rule}\n\
ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
))
};
// Whether alice's edit keeps bob's answer, and whether bob may write it.
let check = |start: &str, old: &str, new: &str| {
let (store, _) = itip::organize(
Some(&series(start, old, "ACCEPTED")),
Some(series(start, new, "ACCEPTED")),
&is(ALICE),
now(),
);
let kept = text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}"));
let copy = series(start, old, "NEEDS-ACTION");
let attended = itip::attend(©, series(start, new, "ACCEPTED"), &is(BOB), now());
(kept, attended.is_ok())
};
let la = "America/Los_Angeles:20260105T170000";
let tokyo = "Asia/Tokyo:20260105T080000";
let daily = "FREQ=DAILY;COUNT=4";
assert_eq!(check(la, daily, "FREQ=DAILY;UNTIL=20260108"), (true, true));
assert_eq!(
check(la, daily, "FREQ=DAILY;UNTIL=20260108T170000"),
(true, true)
);
assert_eq!(
check(tokyo, daily, "FREQ=DAILY;UNTIL=20260108"),
(true, true)
);
// Saturday the 10th in Tokyo and Friday the 9th in Los Angeles are new.
let saturday = "Asia/Tokyo:20260103T080000";
let friday = "America/Los_Angeles:20260102T170000";
let weekly = |until: &str| format!("FREQ=WEEKLY;UNTIL={until}");
assert_eq!(
check(saturday, &weekly("20260109"), &weekly("20260110")),
(false, false)
);
assert_eq!(
check(friday, &weekly("20260108"), &weekly("20260109")),
(false, false)
);
}
#[test]
fn a_range_on_the_last_day_of_a_date_until_ends_the_series_before_it() {
use pimdav::expand::expand;
use pimdav::zone::Zone;
let org = cal(&format!(
"BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
RRULE:FREQ=WEEKLY;UNTIL=20260119\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n\
END:VEVENT\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z\n\
DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
ATTENDEE:{CAROL}\nEND:VEVENT\n"
));
let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
let bob = &to(&msgs, BOB).unwrap().cal;
let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90);
let starts: Vec<_> = expand(bob, window, Zone::Utc)
.instances
.iter()
.map(|i| i.start)
.collect();
assert_eq!(starts, [at(5, 10), at(12, 10)], "{}", text(bob));
}
#[test]
fn far_rule_changes_on_many_components_share_one_budget() {
let never = "BEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\n\
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30\nEND:STANDARD\n";
let zone = format!(
"BEGIN:VTIMEZONE\nTZID:Never\n{}END:VTIMEZONE\n",
never.repeat(20)
);
let series = |until: &str, bob: &str| {
let tail = format!(
"DURATION:PT1S\nRRULE:FREQ=SECONDLY;UNTIL={until}\nORGANIZER:{ALICE}\n\
ATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
);
let mut body =
format!("{zone}BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Never:20260105T100000\n{tail}");
for n in 1..=10 {
body += &format!(
"BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;TZID=Never:20260105T1000{n:02}\n\
DTSTART;TZID=Never:20260105T1000{n:02}\n{tail}"
);
}
cal(&body)
};
let t = std::time::Instant::now();
let (store, _) = itip::organize(
Some(&series("20270101T000000Z", "ACCEPTED")),
Some(series("20570101T000000Z", "ACCEPTED")),
&is(ALICE),
now(),
);
assert!(!text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")));
let copy = series("20270101T000000Z", "NEEDS-ACTION");
let answered = series("20570101T000000Z", "ACCEPTED");
assert!(itip::attend(©, answered, &is(BOB), now()).is_err());
assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
}
Mpimdav/tests/report.rs
@@ -754,3 +754,62 @@ fn an_empty_comp_selects_the_whole_component() {
assert!(out.contains("TZOFFSETTO:+0100"), "{out}");
assert!(!out.contains("SUMMARY"), "{out}");
}
#[test]
fn uri_values_keep_their_commas_and_semicolons() {
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\nRRULE:FREQ=DAILY;COUNT=3\r\n\
LOCATION:A\\, B\r\n\
X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-TITLE=Home:geo:52.5,13.4\r\n\
URL:http://x/a,b;c\r\n\
ATTACH:http://x/f,1;2\r\n\
CONFERENCE;VALUE=URI:https://meet/x,y;z\r\n\
X-GOOGLE-CONFERENCE:https://meet.google.com/a,b\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let req = |inner: &str| {
let xml = format!(
r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-data>{inner}</c:calendar-data></d:prop></c:calendar-multiget>"#
);
let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else {
panic!();
};
props.calendar.unwrap()
};
for inner in [
r#"<c:comp name="VCALENDAR"><c:allprop/><c:comp name="VEVENT"><c:allprop/></c:comp></c:comp>"#,
r#"<c:expand start="20260101T000000Z" end="20260102T000000Z"/>"#,
r#"<c:limit-recurrence-set start="20260101T000000Z" end="20260102T000000Z"/>"#,
] {
let out = calendar_data(body, &req(inner), &Zone::Utc).unwrap().0;
let out = out.replace("\r\n ", "");
assert!(out.contains(":geo:52.5,13.4"), "{out}");
assert!(out.contains("URL:http://x/a,b;c"), "{out}");
assert!(out.contains(":http://x/f,1;2"), "{out}");
assert!(out.contains(":https://meet/x,y;z"), "{out}");
assert!(out.contains(":https://meet.google.com/a,b"), "{out}");
assert!(out.contains("LOCATION:A\\, B"), "{out}");
}
}
#[test]
fn card_uris_and_geo_survive_conversion() {
use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
let to = |v| AddressData {
props: None,
version: Some(v),
};
let v3 = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:g\r\nFN:A\r\n\
X-SOCIALPROFILE;type=x:http://t/a,b;c\r\nGEO:52.5;13.4\r\nEND:VCARD\r\n";
let v4 = address_data(v3, &to(V4_0));
let flat = v4.replace("\r\n ", "");
assert!(flat.contains(":http://t/a,b;c"), "{v4}");
assert!(flat.contains("GEO:geo:52.5,13.4"), "{v4}");
let back = address_data(&v4, &to(V3_0));
let flat = back.replace("\r\n ", "");
assert!(flat.contains(":http://t/a,b;c"), "{back}");
assert!(flat.contains("GEO:52.5;13.4"), "{back}");
// vCard 3 GEO has no altitude.
let high = v4.replace("geo:52.5,13.4", "geo:52.5,13.4,34");
let flat = address_data(&high, &to(V3_0)).replace("\r\n ", "");
assert!(flat.contains("GEO:52.5;13.4\r\n"), "{flat}");
}
Mserver/src/api/pim.rs
@@ -2159,6 +2159,20 @@ impl Cx<'_> {
if refuses(headers, current.as_ref().map(|(o, _)| o)) {
return Ok(status(StatusCode::PRECONDITION_FAILED));
}
// A card stored without UID may gain one.
let renamed = current.as_ref().is_some_and(|(o, stored)| {
o.uid != uid
&& (*kind == PimKind::Calendar || object::vcard(stored).is_ok_and(|u| u.is_some()))
});
if renamed {
return Ok(error(
StatusCode::FORBIDDEN,
with_children(
el(ns, "no-uid-conflict"),
hrefs([space.object(*kind, slug, name).as_str()]),
),
));
}
if let Some(holder) = db.pim_uid_holder(col.id, &uid, name).await? {
return Ok(error(
StatusCode::FORBIDDEN,
Mserver/src/api/pim_schedule.rs
@@ -21,6 +21,7 @@ use pimdav::filter::TimeRange;
use pimdav::freebusy::{self, Period};
use pimdav::itip::{self, Message, Method, Role};
use pimdav::principal::UserType;
use pimdav::render;
use pimdav::xml::{CALDAV, el, hrefs, with_children};
use pimdav::zone::{self, Zone};
use sha2::{Digest, Sha256};
@@ -325,7 +326,7 @@ pub(crate) async fn put(
};
let changed = stored != sent;
let data = match changed {
true => stored.to_string().into_bytes(),
true => render::write(&stored).into_bytes(),
false => body.to_vec(),
};
Ok(Ok(Stored {
@@ -669,7 +670,7 @@ async fn deliver(
return Ok(Some(NO_AUTHORITY));
};
if let Some(next) = itip::receive(copy.as_ref().map(|(_, _, c)| c), m) {
let data = next.to_string().into_bytes();
let data = render::write(&next).into_bytes();
let etag = etag_of(&data);
let (collection_id, name, schedule_tag) = match copy {
// Only the others' answers changed: the attendee's pending edit
@@ -751,7 +752,7 @@ async fn reply_to(
let mut after = before.clone();
let applied = itip::apply_reply(&mut after, &m.cal, &replier);
if applied.changed {
let data = after.to_string().into_bytes();
let data = render::write(&after).into_bytes();
ops.push(PimOp::Put {
collection_id,
obj: PimObject {
@@ -812,7 +813,7 @@ async fn ensure(state: &AppState, p: &PimPrincipal) -> Result<(), ApiError> {
}
fn inbox(p: &PimPrincipal, m: &Message, component: &str) -> PimOp {
let data = m.cal.to_string().into_bytes();
let data = render::write(&m.cal).into_bytes();
let stamp = Utc::now().timestamp_nanos_opt().unwrap_or_default();
let seed = format!(
"{}\n{}\n{stamp}\n{:?}",
Mserver/src/api/pim_views.rs
@@ -24,6 +24,7 @@ use pimdav::calcard::icalendar::{
use pimdav::expand::expand;
use pimdav::itip::{self, Role};
use pimdav::principal::UserType;
use pimdav::render;
use pimdav::view::{self, Card, EventInfo, Person};
use pimdav::zone::{self, Zone};
use serde::Deserialize;
@@ -540,8 +541,8 @@ pub async fn reply(
}
let zone = floating(body.tz.as_deref());
let new = itip::respond(&cal, &owns, answer, rid, &zone)
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "instance not found"))?
.to_string();
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "instance not found"))?;
let new = render::write(&new);
let me = state.db.principal_of(auth.user.id).await?;
let w = Writer {
owner: &principal,
Mserver/src/db.rs
@@ -2507,6 +2507,8 @@ fn apply_ops(tx: &rusqlite::Transaction, ops: &[PimOp]) -> DbResult<()> {
obj,
data,
} => {
// A room gets its inbox with its first message.
ensure_inbox(tx, *principal_id)?;
let inbox: i64 = tx.query_row(
"SELECT id FROM pim_collections
WHERE principal_id = ?1 AND kind = 'cal' AND slug = 'inbox'",
Mserver/tests/api/pim_schedule.rs
@@ -1478,3 +1478,95 @@ async fn a_plain_loan_may_bump_the_sequence() {
let org = unfold(&pim.get("alice", ALICE_EVENT).await);
assert!(org.contains("PARTSTAT=ACCEPTED"), "{org}");
}
#[tokio::test]
async fn a_room_organizer_gets_its_inbox_with_the_first_reply() {
let pim = Pim::new().await;
pim.room().await;
let admin = |verb: &str, path: &str, body: &str| {
let auth = basic("admin", "admin1234");
let (verb, path, body) = (verb.to_string(), path.to_string(), body.to_string());
let app = pim.env.app.clone();
async move {
Client::new(app)
.raw(
Method::from_bytes(verb.as_bytes()).unwrap(),
&path,
&[("authorization", auth.as_str())],
body.into_bytes(),
)
.await
}
};
let event = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nBEGIN:VEVENT\r\nUID:room-1\r\n\
DTSTAMP:20260101T000000Z\r\nDTSTART:{}\r\nDURATION:PT1H\r\n\
ORGANIZER:{ROOM}\r\nATTENDEE:{}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
future(4, 9),
addr("alice")
);
let r = admin("PUT", "/pim/calendars/board/default/r.ics", &event).await;
assert!(r.status.is_success(), "{}: {}", r.status, r.text());
let (href, copy) = pim.copy("alice").await;
let accepted = unfold(©.text()).replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED");
let r = pim.req("alice", "PUT", &href, &[], &accepted).await;
assert!(r.status.is_success(), "{}: {}", r.status, r.text());
let db = &pim.env.state.db;
let room = db.pim_principal("board").await.unwrap().unwrap();
let inbox = db
.pim_collection(room.id, server::db::PimKind::Calendar, "inbox")
.await
.unwrap()
.unwrap();
assert_eq!(db.pim_objects(inbox.id).await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_put_may_not_change_the_uid_of_an_object() {
let pim = Pim::new().await;
invite(&pim, &[&addr("bob")]).await;
let other = meeting("20260301T100000Z", &[&addr("bob")]).replace("UID:meet-1", "UID:meet-2");
let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &other).await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict"));
let card = |uid: &str| format!("BEGIN:VCARD\r\nVERSION:3.0\r\n{uid}FN:A\r\nEND:VCARD\r\n");
let path = "/pim/addressbooks/alice/default/a.vcf";
// A card may gain a UID, but not change it.
pim.put_ok("alice", path, &card("")).await;
pim.put_ok("alice", path, &card("UID:c-1\r\n")).await;
let r = pim
.req("alice", "PUT", path, &[], &card("UID:c-2\r\n"))
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
// Nor drop it, nor change a UID that equals the name.
let r = pim.req("alice", "PUT", path, &[], &card("")).await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
let named = "/pim/addressbooks/alice/default/b.vcf";
pim.put_ok("alice", named, &card("UID:b.vcf\r\n")).await;
let r = pim
.req("alice", "PUT", named, &[], &card("UID:b-2\r\n"))
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.text());
}
#[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";
let body = meeting("20260301T100000Z", &[&addr("bob")]).replace(
"SUMMARY:Planning\r\n",
&format!("SUMMARY:Planning\r\n{uris}"),
);
let r = pim.req("alice", "PUT", ALICE_EVENT, &[], &body).await;
assert!(r.status.is_success(), "{}", r.text());
let org = unfold(&pim.req("alice", "GET", ALICE_EVENT, &[], "").await.text());
let copy = unfold(&pim.copy("bob").await.1.text());
let inbox = unfold(&pim.inbox("bob").await[0]);
for text in [&org, ©, &inbox] {
assert!(
text.contains("https://meet/a,b") && text.contains("http://x/a,b;c"),
"{text}"
);
}
}