CalDAV/CardDAV reports: queries, multiget, sync, free-busy, MOVE
- pimdav: REPORT parsing, calendar-query and addressbook-query filters (time-range per RFC 4791 9.9 incl. VTODO, VALARM, VFREEBUSY; collations), calendar-data comp/prop selection, expand, limit-recurrence-set, limit-freebusy-set, address-data selection and version conversion - Free-busy: merged BUSY, BUSY-TENTATIVE, BUSY-UNAVAILABLE periods - sync-collection with token validation, tombstones and nresults limit - Multistatus responses with per-href status and a trailing sync-token - MOVE of objects between the user's own collections - Hrefs follow the user name spelling of the request - New calendars also take VJOURNAL Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mpimdav/src/expand.rs
@@ -71,16 +71,16 @@ pub fn expand(cal: &ICalendar, window: Range<DateTime<Utc>>, floating: Zone) ->
/// A date or date-time value and the zone it is in.
#[derive(Debug, Clone)]
struct Stamp {
local: NaiveDateTime,
pub(crate) struct Stamp {
pub(crate) local: NaiveDateTime,
zone: Zone,
date: bool,
pub(crate) date: bool,
/// A date-time without TZID or UTC offset.
floating: bool,
}
impl Stamp {
fn utc(&self) -> DateTime<Utc> {
pub(crate) fn utc(&self) -> DateTime<Utc> {
self.zone.to_utc(self.local)
}
}
@@ -518,7 +518,7 @@ fn nominal(d: &ICalendarDuration) -> Length {
}
}
fn stamp(zones: &Zones, v: &PartialDateTime, tzid: Option<&str>) -> Option<Stamp> {
pub(crate) fn stamp(zones: &Zones, v: &PartialDateTime, tzid: Option<&str>) -> Option<Stamp> {
let dt = v.to_date_time()?;
let date = v.hour.is_none();
let zone = match dt.offset {
Apimdav/src/filter.rs
@@ -0,0 +1,640 @@
//! Query filters: `calendar-query` (RFC 4791, 9.7) and `addressbook-query`
//! (RFC 6352, 10.5). Parsing and evaluation.
use std::borrow::Cow;
use std::ops::Range;
use calcard::icalendar::{
ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
ICalendarParameterValue, ICalendarProperty, ICalendarRelated, ICalendarValue,
};
use calcard::vcard::{VCard, VCardVersion};
use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
use xmltree::Element;
use crate::expand::{expand, stamp};
use crate::freebusy::periods;
use crate::report::Refused;
use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text};
use crate::zone::{Zone, Zones, add};
pub type TimeRange = Range<DateTime<Utc>>;
#[derive(Debug, Clone, PartialEq)]
pub struct CompFilter {
/// Upper case.
pub name: String,
pub not_defined: bool,
pub time_range: Option<TimeRange>,
pub props: Vec<PropFilter>,
pub comps: Vec<CompFilter>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PropFilter {
pub name: String,
pub not_defined: bool,
pub time_range: Option<TimeRange>,
pub text: Option<TextMatch>,
pub params: Vec<ParamFilter>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParamFilter {
pub name: String,
pub not_defined: bool,
pub text: Option<TextMatch>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CardFilter {
/// `test="allof"`. The default is `anyof`.
pub all: bool,
pub props: Vec<CardPropFilter>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CardPropFilter {
pub name: String,
pub all: bool,
pub not_defined: bool,
pub texts: Vec<TextMatch>,
pub params: Vec<ParamFilter>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextMatch {
pub text: String,
pub collation: Collation,
pub match_type: MatchType,
pub negate: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Collation {
Octet,
AsciiCasemap,
UnicodeCasemap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchType {
Equals,
Contains,
StartsWith,
EndsWith,
}
impl Collation {
fn fold<'a>(self, s: &'a str) -> Cow<'a, str> {
match self {
Collation::Octet => Cow::Borrowed(s),
Collation::AsciiCasemap => Cow::Owned(s.to_ascii_lowercase()),
// ponytail: plain lowercasing, without the NFKD step of RFC 5051.
Collation::UnicodeCasemap => Cow::Owned(s.to_lowercase()),
}
}
}
impl TextMatch {
/// `ns` picks the defaults: CalDAV folds ASCII only and CardDAV Unicode.
fn parse(e: &Element, ns: &str) -> Result<Self, Refused> {
let collation = match e.attributes.get("collation").map(String::as_str) {
None if ns == CARDDAV => Collation::UnicodeCasemap,
None => Collation::AsciiCasemap,
Some("i;octet") => Collation::Octet,
Some("i;ascii-casemap") => Collation::AsciiCasemap,
Some("i;unicode-casemap") => Collation::UnicodeCasemap,
Some(_) => return Err(Refused::Condition(Name::new(ns, "supported-collation"))),
};
let match_type = match e.attributes.get("match-type").map(String::as_str) {
None | Some("contains") => MatchType::Contains,
Some("equals") => MatchType::Equals,
Some("starts-with") => MatchType::StartsWith,
Some("ends-with") => MatchType::EndsWith,
Some(_) => return Err(Refused::Invalid),
};
Ok(TextMatch {
text: text(e),
collation,
match_type,
negate: e.attributes.get("negate-condition").map(String::as_str) == Some("yes"),
})
}
pub fn matches(&self, value: &str) -> bool {
let (v, t) = (self.collation.fold(value), self.collation.fold(&self.text));
let hit = match self.match_type {
MatchType::Equals => v == t,
MatchType::Contains => v.contains(&*t),
MatchType::StartsWith => v.starts_with(&*t),
MatchType::EndsWith => v.ends_with(&*t),
};
hit != self.negate
}
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
/// The `<C:filter>` of a calendar-query.
pub fn calendar_filter(e: &Element) -> Result<CompFilter, Refused> {
let root = comp_filter(child(e, CALDAV, "comp-filter").ok_or(Refused::Invalid)?)?;
if root.name != "VCALENDAR" {
return Err(Refused::Condition(Name::new(CALDAV, "valid-filter")));
}
Ok(root)
}
fn comp_filter(e: &Element) -> Result<CompFilter, Refused> {
let mut f = CompFilter {
name: name_attr(e)?.to_ascii_uppercase(),
not_defined: false,
time_range: None,
props: Vec::new(),
comps: Vec::new(),
};
for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) {
match c.name.as_str() {
"is-not-defined" => f.not_defined = true,
"time-range" => f.time_range = Some(time_range(c)?),
"prop-filter" => f.props.push(prop_filter(c)?),
"comp-filter" => f.comps.push(comp_filter(c)?),
_ => {}
}
}
Ok(f)
}
fn prop_filter(e: &Element) -> Result<PropFilter, Refused> {
let mut f = PropFilter {
name: name_attr(e)?.to_string(),
not_defined: false,
time_range: None,
text: None,
params: Vec::new(),
};
for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) {
match c.name.as_str() {
"is-not-defined" => f.not_defined = true,
"time-range" => f.time_range = Some(time_range(c)?),
"text-match" => f.text = Some(TextMatch::parse(c, CALDAV)?),
"param-filter" => f.params.push(param_filter(c, CALDAV)?),
_ => {}
}
}
Ok(f)
}
fn param_filter(e: &Element, ns: &str) -> Result<ParamFilter, Refused> {
let mut f = ParamFilter {
name: name_attr(e)?.to_string(),
not_defined: false,
text: None,
};
for c in elements(e).filter(|c| Name::of(c).ns == ns) {
match c.name.as_str() {
"is-not-defined" => f.not_defined = true,
"text-match" => f.text = Some(TextMatch::parse(c, ns)?),
_ => {}
}
}
Ok(f)
}
/// The `<CR:filter>` of an addressbook-query.
pub fn card_filter(e: &Element) -> Result<CardFilter, Refused> {
let props = elements(e)
.filter(|c| Name::of(c).is(CARDDAV, "prop-filter"))
.map(card_prop_filter)
.collect::<Result<_, _>>()?;
Ok(CardFilter {
all: all_of(e)?,
props,
})
}
fn card_prop_filter(e: &Element) -> Result<CardPropFilter, Refused> {
let mut f = CardPropFilter {
name: name_attr(e)?.to_string(),
all: all_of(e)?,
not_defined: false,
texts: Vec::new(),
params: Vec::new(),
};
for c in elements(e).filter(|c| Name::of(c).ns == CARDDAV) {
match c.name.as_str() {
"is-not-defined" => f.not_defined = true,
"text-match" => f.texts.push(TextMatch::parse(c, CARDDAV)?),
"param-filter" => f.params.push(param_filter(c, CARDDAV)?),
_ => {}
}
}
Ok(f)
}
fn all_of(e: &Element) -> Result<bool, Refused> {
match e.attributes.get("test").map(String::as_str) {
None | Some("anyof") => Ok(false),
Some("allof") => Ok(true),
Some(_) => Err(Refused::Invalid),
}
}
fn name_attr(e: &Element) -> Result<&str, Refused> {
e.attributes
.get("name")
.map(String::as_str)
.ok_or(Refused::Invalid)
}
/// A `time-range` or `expand` element. A missing bound is open.
pub fn time_range(e: &Element) -> Result<TimeRange, Refused> {
let at = |k: &str| {
e.attributes
.get(k)
.map(|v| {
NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%SZ")
.map(|t| t.and_utc())
.map_err(|_| Refused::Invalid)
})
.transpose()
};
let start = at("start")?.unwrap_or(DateTime::<Utc>::MIN_UTC);
let end = at("end")?.unwrap_or(DateTime::<Utc>::MAX_UTC);
if start >= end {
return Err(Refused::Invalid);
}
Ok(start..end)
}
// ---------------------------------------------------------------------------
// Calendar evaluation
// ---------------------------------------------------------------------------
/// Whether a calendar object matches `filter`. `floating` interprets values
/// without a zone.
pub fn matches_calendar(cal: &ICalendar, filter: &CompFilter, floating: &Zone) -> bool {
let ctx = Ctx {
cal,
zones: Zones::new(cal, floating.clone()),
floating,
};
ctx.comp(None, filter)
}
struct Ctx<'a> {
cal: &'a ICalendar,
zones: Zones,
floating: &'a Zone,
}
impl Ctx<'_> {
fn comp(&self, parent: Option<usize>, f: &CompFilter) -> bool {
let children: Vec<usize> = match parent {
None => (!self.cal.components.is_empty())
.then_some(0)
.into_iter()
.collect(),
Some(p) => self.cal.components[p]
.component_ids
.iter()
.map(|&i| i as usize)
.collect(),
};
let mut found = children.into_iter().filter(|&i| {
self.cal.components[i]
.component_type
.as_str()
.eq_ignore_ascii_case(&f.name)
});
if f.not_defined {
return found.next().is_none();
}
found.any(|i| {
f.time_range.as_ref().is_none_or(|r| self.overlaps(i, r))
&& f.props.iter().all(|p| self.prop(i, p))
&& f.comps.iter().all(|c| self.comp(Some(i), c))
})
}
/// The time-range rules of RFC 4791, 9.9.
fn overlaps(&self, i: usize, r: &TimeRange) -> bool {
let c = &self.cal.components[i];
let has = |p: ICalendarProperty| c.has_property(&p);
let time = |p: ICalendarProperty| {
let e = c.property(&p)?;
stamp(
&self.zones,
e.values.first()?.as_partial_date_time()?,
e.tz_id(),
)
.map(|s| s.utc())
};
match c.component_type {
ICalendarComponentType::VEvent
| ICalendarComponentType::VTodo
| ICalendarComponentType::VJournal
if has(ICalendarProperty::Dtstart) =>
{
let todo = c.component_type == ICalendarComponentType::VTodo;
let (due, duration) = (
has(ICalendarProperty::Due),
has(ICalendarProperty::Duration),
);
// One second wider, so that the exact rules below decide the
// instances that only touch the range.
let second = TimeDelta::seconds(1);
let exp = expand(
self.cal,
add(r.start, -second)..add(r.end, second),
self.floating.clone(),
);
// Unknown instances may overlap, so the object stays in.
if exp.truncated {
return true;
}
exp.instances.iter().filter(|x| x.component == i).any(|x| {
let (s, e) = (x.start, x.end);
match (todo, due, duration) {
(true, _, true) => r.start <= e && (r.end > s || r.end >= e),
(true, true, _) => {
(r.start < e || r.start <= s) && (r.end > s || r.end >= e)
}
(true, ..) => r.start <= s && r.end > s,
_ if s == e => r.start <= s && r.end > s,
_ => r.start < e && r.end > s,
}
})
}
ICalendarComponentType::VTodo => match (
time(ICalendarProperty::Due),
time(ICalendarProperty::Completed),
time(ICalendarProperty::Created),
) {
(Some(due), _, _) => r.start < due && r.end >= due,
(None, Some(done), Some(made)) => {
(r.start <= made || r.start <= done) && (r.end >= made || r.end >= done)
}
(None, Some(done), None) => r.start <= done && r.end >= done,
(None, None, Some(made)) => r.end > made,
(None, None, None) => true,
},
ICalendarComponentType::VFreebusy => {
let busy: Vec<_> = c
.properties(&ICalendarProperty::Freebusy)
.flat_map(|e| periods(&self.zones, e))
.collect();
if !busy.is_empty() {
return busy.iter().any(|(s, e)| r.start < *e && r.end > *s);
}
match (
time(ICalendarProperty::Dtstart),
time(ICalendarProperty::Dtend),
) {
(Some(s), Some(e)) => r.start <= e && r.end > s,
_ => false,
}
}
ICalendarComponentType::VAlarm => self.alarm_overlaps(i, r),
_ => false,
}
}
/// Whether a trigger of the alarm, repetitions included, falls into `r`.
/// A relative trigger fires once per instance of the parent component.
fn alarm_overlaps(&self, alarm: usize, r: &TimeRange) -> bool {
let a = &self.cal.components[alarm];
let Some(trigger) = a.property(&ICalendarProperty::Trigger) else {
return false;
};
let repeat = a
.property(&ICalendarProperty::Repeat)
.and_then(|e| e.values.first()?.as_integer())
.unwrap_or(0)
.clamp(0, 1000) as i32;
let every = match a
.property(&ICalendarProperty::Duration)
.and_then(|e| e.values.first())
{
Some(ICalendarValue::Duration(d)) => d.to_time_delta().unwrap_or_default(),
_ => TimeDelta::zero(),
};
let hit = |base: DateTime<Utc>| {
(0..=repeat).any(|k| {
let t = add(base, every.checked_mul(k).unwrap_or(TimeDelta::MAX));
r.start <= t && r.end > t
})
};
match trigger.values.first() {
Some(ICalendarValue::PartialDateTime(p)) => {
stamp(&self.zones, p, trigger.tz_id()).is_some_and(|s| hit(s.utc()))
}
Some(ICalendarValue::Duration(d)) => {
let Some(offset) = d.to_time_delta() else {
return false;
};
let Some(parent) = self
.cal
.components
.iter()
.position(|c| c.component_ids.contains(&(alarm as u32)))
else {
return false;
};
let from_end = trigger.parameter(&ICalendarParameterName::Related)
== Some(&ICalendarParameterValue::Related(ICalendarRelated::End));
let reach = offset
.abs()
.checked_add(&every.checked_mul(repeat).unwrap_or(TimeDelta::MAX))
.unwrap_or(TimeDelta::MAX)
.checked_add(&TimeDelta::seconds(1))
.unwrap_or(TimeDelta::MAX);
let exp = expand(
self.cal,
add(r.start, -reach)..add(r.end, reach),
self.floating.clone(),
);
if exp.truncated {
return true;
}
exp.instances
.iter()
.filter(|x| x.component == parent)
.any(|x| hit(add(if from_end { x.end } else { x.start }, offset)))
}
_ => false,
}
}
fn prop(&self, i: usize, f: &PropFilter) -> bool {
let mut found = self.cal.components[i]
.entries
.iter()
.filter(|e| e.name.as_str().eq_ignore_ascii_case(&f.name));
if f.not_defined {
return found.next().is_none();
}
found.any(|e| {
let l = ical_line(e);
f.time_range.as_ref().is_none_or(|r| {
e.values
.iter()
.filter_map(|v| stamp(&self.zones, v.as_partial_date_time()?, e.tz_id()))
.any(|s| r.start <= s.utc() && r.end > s.utc())
}) && f.text.as_ref().is_none_or(|t| t.matches(&l.value))
&& f.params.iter().all(|p| param_matches(&l, p))
})
}
}
// ---------------------------------------------------------------------------
// Card evaluation
// ---------------------------------------------------------------------------
pub fn matches_card(card: &VCard, f: &CardFilter) -> bool {
if f.props.is_empty() {
return true;
}
let v4 = card.version() == Some(VCardVersion::V4_0);
let lines: Vec<Line> = card
.entries
.iter()
.map(|e| {
let mut s = String::new();
let _ = e.write_to(&mut s, v4);
line(&s)
})
.collect();
let hit = |p: &CardPropFilter| card_prop(&lines, p);
if f.all {
f.props.iter().all(hit)
} else {
f.props.iter().any(hit)
}
}
/// Each text-match and param-filter is tested against every instance of the
/// property; `test` combines their results.
fn card_prop(lines: &[Line], f: &CardPropFilter) -> bool {
let found: Vec<&Line> = lines
.iter()
.filter(|l| l.name.eq_ignore_ascii_case(&f.name))
.collect();
if f.not_defined {
return found.is_empty();
}
if found.is_empty() {
return false;
}
let mut tests = f
.texts
.iter()
.map(|t| found.iter().any(|l| t.matches(&l.value)))
.chain(
f.params
.iter()
.map(|p| found.iter().any(|l| param_matches(l, p))),
)
.peekable();
if tests.peek().is_none() {
return true;
}
if f.all {
tests.all(|b| b)
} else {
tests.any(|b| b)
}
}
fn param_matches(l: &Line, f: &ParamFilter) -> bool {
match l
.params
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(&f.name))
{
None => f.not_defined,
Some(_) if f.not_defined => false,
Some((_, values)) => f
.text
.as_ref()
.is_none_or(|t| values.iter().any(|v| t.matches(v))),
}
}
// ---------------------------------------------------------------------------
// Content lines
// ---------------------------------------------------------------------------
/// A property as text, the way text-match sees it: the group dropped, the
/// parameter values unquoted, the value unescaped.
struct Line {
name: String,
params: Vec<(String, Vec<String>)>,
value: String,
}
fn ical_line(e: &ICalendarEntry) -> Line {
let mut s = String::new();
let _ = e.write_to(&mut s);
line(&s)
}
/// Parses one content line as calcard's writer produces it.
fn line(written: &str) -> Line {
let unfolded = written.replace("\r\n ", "").replace("\r\n\t", "");
let unfolded = unfolded.trim_end_matches(['\r', '\n']);
let head = split_unquoted(unfolded, ':')[0];
let value = unfolded.get(head.len() + 1..).unwrap_or_default();
let mut head = split_unquoted(head, ';').into_iter();
let name = head.next().unwrap_or_default();
let name = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase();
let params = head
.filter_map(|p| p.split_once('='))
.map(|(k, v)| {
let values = split_unquoted(v, ',')
.into_iter()
.map(|v| v.trim_matches('"').to_string())
.collect();
(k.to_ascii_uppercase(), values)
})
.collect();
Line {
name,
params,
value: unescape(value),
}
}
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut out = Vec::new();
let (mut quoted, mut from) = (false, 0);
for (i, c) in s.char_indices() {
if c == '"' {
quoted = !quoted;
} else if c == sep && !quoted {
out.push(&s[from..i]);
from = i + 1;
}
}
out.push(&s[from..]);
out
}
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n' | 'N') => out.push('\n'),
Some(c) => out.push(c),
None => out.push('\\'),
}
}
out
}
Apimdav/src/freebusy.rs
@@ -0,0 +1,149 @@
//! Busy time of calendar objects, for free-busy-query (RFC 4791, 7.10).
use calcard::icalendar::{
ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarFreeBusyType,
ICalendarParameterName, ICalendarParameterValue, ICalendarPeriod, ICalendarProperty,
ICalendarStatus, ICalendarTransparency, ICalendarValue,
};
use chrono::{DateTime, Utc};
use crate::expand::{expand, stamp};
use crate::filter::TimeRange;
use crate::zone::{Zone, Zones, add};
/// In the order a client ranks them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Busy {
Busy,
Tentative,
Unavailable,
}
impl Busy {
fn fbtype(self) -> &'static str {
match self {
Busy::Busy => "BUSY",
Busy::Tentative => "BUSY-TENTATIVE",
Busy::Unavailable => "BUSY-UNAVAILABLE",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Period {
pub kind: Busy,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
/// The busy periods of one calendar object, clipped to `range`. Transparent
/// and cancelled events are free; VFREEBUSY components count as stored.
pub fn busy(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> Vec<Period> {
let zones = Zones::new(cal, floating.clone());
let mut out = Vec::new();
let mut push = |kind, start: DateTime<Utc>, end: DateTime<Utc>| {
let (start, end) = (start.max(range.start), end.min(range.end));
if start < end {
out.push(Period { kind, start, end });
}
};
for x in expand(cal, range.clone(), floating.clone()).instances {
let c = &cal.components[x.component];
if c.component_type != ICalendarComponentType::VEvent
|| c.transparency() == Some(&ICalendarTransparency::Transparent)
{
continue;
}
let kind = match c.status() {
Some(ICalendarStatus::Cancelled) => continue,
Some(ICalendarStatus::Tentative) => Busy::Tentative,
_ => Busy::Busy,
};
push(kind, x.start, x.end);
}
for c in cal
.components
.iter()
.filter(|c| c.component_type == ICalendarComponentType::VFreebusy)
{
for e in c.properties(&ICalendarProperty::Freebusy) {
let kind = match e.parameter(&ICalendarParameterName::Fbtype) {
Some(ICalendarParameterValue::Fbtype(ICalendarFreeBusyType::Free)) => continue,
Some(ICalendarParameterValue::Fbtype(ICalendarFreeBusyType::BusyTentative)) => {
Busy::Tentative
}
Some(ICalendarParameterValue::Fbtype(ICalendarFreeBusyType::BusyUnavailable)) => {
Busy::Unavailable
}
_ => Busy::Busy,
};
for (s, e) in periods(&zones, e) {
push(kind, s, e);
}
}
}
out
}
/// Sorts the periods and joins those of one kind that overlap or touch.
pub fn merge(mut periods: Vec<Period>) -> Vec<Period> {
periods.sort_by_key(|p| (p.kind, p.start));
let mut out: Vec<Period> = Vec::with_capacity(periods.len());
for p in periods {
match out.last_mut() {
Some(last) if last.kind == p.kind && p.start <= last.end => {
last.end = last.end.max(p.end);
}
_ => out.push(p),
}
}
out
}
/// The VCALENDAR a free-busy-query answers with.
pub fn vfreebusy(periods: &[Period], range: &TimeRange, now: DateTime<Utc>) -> String {
let t = |d: DateTime<Utc>| d.format("%Y%m%dT%H%M%SZ").to_string();
let mut out = format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//filebrowser-ng//pimdav//EN\r\n\
BEGIN:VFREEBUSY\r\nDTSTAMP:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
t(now),
t(range.start),
t(range.end)
);
for p in periods {
out.push_str(&format!(
"FREEBUSY;FBTYPE={}:{}/{}\r\n",
p.kind.fbtype(),
t(p.start),
t(p.end)
));
}
out.push_str("END:VFREEBUSY\r\nEND:VCALENDAR\r\n");
out
}
/// The PERIOD values of a FREEBUSY property.
pub(crate) fn periods(zones: &Zones, e: &ICalendarEntry) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
e.values
.iter()
.filter_map(|v| period(zones, e.tz_id(), v))
.collect()
}
pub(crate) fn period(
zones: &Zones,
tzid: Option<&str>,
v: &ICalendarValue,
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
match v {
ICalendarValue::Period(ICalendarPeriod::Range { start, end }) => Some((
stamp(zones, start, tzid)?.utc(),
stamp(zones, end, tzid)?.utc(),
)),
ICalendarValue::Period(ICalendarPeriod::Duration { start, duration }) => {
let s = stamp(zones, start, tzid)?.utc();
Some((s, add(s, duration.to_time_delta()?)))
}
_ => None,
}
}
Mpimdav/src/lib.rs
@@ -2,7 +2,11 @@
//! this crate computes on it.
pub mod expand;
pub mod filter;
pub mod freebusy;
pub mod object;
pub mod render;
pub mod report;
pub mod xml;
pub mod zone;
Apimdav/src/render.rs
@@ -0,0 +1,377 @@
//! The `calendar-data` and `address-data` a REPORT returns: the whole
//! object, a part of it, or its recurrence set expanded (RFC 4791, 9.6;
//! RFC 6352, 10.4).
use std::collections::HashSet;
use calcard::common::PartialDateTime;
use calcard::icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter,
ICalendarParameterName, ICalendarParameterValue, ICalendarProperty, ICalendarValue,
ICalendarValueType,
};
use calcard::vcard::{VCard, VCardVersion};
use chrono::{DateTime, Utc};
use xmltree::Element;
use crate::expand::expand;
use crate::filter::{TimeRange, time_range};
use crate::freebusy::period;
use crate::report::Refused;
use crate::xml::{CALDAV, CARDDAV, Name, elements};
use crate::zone::{Zone, Zones};
/// Instances one object may expand into in one response.
const MAX_EXPANDED: usize = 10_000;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CalendarData {
/// `None` returns every component and property.
pub comp: Option<CompSelect>,
pub expand: Option<TimeRange>,
pub limit_recurrence: Option<TimeRange>,
pub limit_freebusy: Option<TimeRange>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompSelect {
/// Upper case.
pub name: String,
/// `None` for `allprop`.
pub props: Option<Vec<PropSelect>>,
/// `None` for `allcomp`.
pub comps: Option<Vec<CompSelect>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PropSelect {
pub name: String,
pub novalue: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AddressData {
/// `None` for `allprop`.
pub props: Option<Vec<PropSelect>>,
pub version: Option<VCardVersion>,
}
/// The expansion would exceed [`MAX_EXPANDED`] instances.
#[derive(Debug, PartialEq, Eq)]
pub struct TooManyInstances;
pub fn calendar_request(e: &Element) -> Result<CalendarData, Refused> {
let content_type = e.attributes.get("content-type").map(String::as_str);
let version = e.attributes.get("version").map(String::as_str);
if content_type.is_some_and(|t| t != "text/calendar") || version.is_some_and(|v| v != "2.0") {
return Err(Refused::Condition(Name::new(
CALDAV,
"supported-calendar-data",
)));
}
let mut d = CalendarData::default();
for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) {
match c.name.as_str() {
"comp" => d.comp = Some(comp_select(c)?),
"expand" => d.expand = Some(time_range(c)?),
"limit-recurrence-set" => d.limit_recurrence = Some(time_range(c)?),
"limit-freebusy-set" => d.limit_freebusy = Some(time_range(c)?),
_ => {}
}
}
Ok(d)
}
fn comp_select(e: &Element) -> Result<CompSelect, Refused> {
let name = e.attributes.get("name").ok_or(Refused::Invalid)?;
let has = |local: &str| elements(e).any(|c| Name::of(c).is(CALDAV, local));
let props = (!has("allprop")).then(|| prop_selects(e, CALDAV));
let comps = match has("allcomp") {
true => None,
false => Some(
elements(e)
.filter(|c| Name::of(c).is(CALDAV, "comp"))
.map(comp_select)
.collect::<Result<_, _>>()?,
),
};
Ok(CompSelect {
name: name.to_ascii_uppercase(),
props,
comps,
})
}
fn prop_selects(e: &Element, ns: &str) -> Vec<PropSelect> {
elements(e)
.filter(|c| Name::of(c).is(ns, "prop"))
.filter_map(|c| {
Some(PropSelect {
name: c.attributes.get("name")?.clone(),
novalue: c.attributes.get("novalue").map(String::as_str) == Some("yes"),
})
})
.collect()
}
pub fn address_request(e: &Element) -> Result<AddressData, Refused> {
let unsupported = || Refused::Condition(Name::new(CARDDAV, "supported-address-data"));
if e.attributes
.get("content-type")
.is_some_and(|t| t != "text/vcard")
{
return Err(unsupported());
}
let version = match e.attributes.get("version").map(String::as_str) {
None => None,
Some("3.0") => Some(VCardVersion::V3_0),
Some("4.0") => Some(VCardVersion::V4_0),
Some(_) => return Err(unsupported()),
};
let all = elements(e).any(|c| Name::of(c).is(CARDDAV, "allprop"));
let props = prop_selects(e, CARDDAV);
Ok(AddressData {
props: (!all && !props.is_empty()).then_some(props),
version,
})
}
/// The calendar-data of one object. The stored text is returned unchanged
/// unless the request narrows or expands it.
pub fn calendar_data(
raw: &str,
req: &CalendarData,
floating: &Zone,
) -> Result<String, TooManyInstances> {
if *req == CalendarData::default() {
return Ok(raw.to_string());
}
let Ok(mut cal) = ICalendar::parse(raw) else {
return Ok(raw.to_string());
};
if let Some(r) = &req.expand {
cal = expanded(&cal, r, floating)?;
}
if let Some(r) = &req.limit_recurrence {
cal = limit_recurrence(&cal, r, floating);
}
if let Some(r) = &req.limit_freebusy {
limit_freebusy(&mut cal, r, floating);
}
if let Some(sel) = &req.comp {
let mut out = Vec::new();
copy(&cal, 0, Some(sel), &mut out);
cal = ICalendar { components: out };
}
Ok(cal.to_string())
}
/// One component per instance in `range`, in UTC, with a RECURRENCE-ID and
/// without recurrence rules (RFC 4791, 9.6.5). Dates stay dates.
fn expanded(
cal: &ICalendar,
range: &TimeRange,
floating: &Zone,
) -> Result<ICalendar, TooManyInstances> {
let exp = expand(cal, range.clone(), floating.clone());
if exp.truncated || exp.instances.len() > MAX_EXPANDED {
return Err(TooManyInstances);
}
let mut out = vec![root(cal)];
for x in exp.instances {
let src = &cal.components[x.component];
let date = src
.property(&ICalendarProperty::Dtstart)
.and_then(|e| e.values.first()?.as_partial_date_time())
.is_some_and(|p| p.hour.is_none());
let at = copy(cal, x.component, None, &mut out);
let c = &mut out[at as usize];
c.entries.retain(|e| {
!matches!(
e.name,
ICalendarProperty::Rrule
| ICalendarProperty::Rdate
| ICalendarProperty::Exdate
| ICalendarProperty::Exrule
| ICalendarProperty::Dtstart
| ICalendarProperty::Dtend
| ICalendarProperty::Due
| ICalendarProperty::RecurrenceId
)
});
let when = |name: ICalendarProperty, t: DateTime<Utc>| {
let (value, params) = if date {
let day = floating.to_local(t).and_utc().timestamp();
let param = ICalendarParameter::new(
ICalendarParameterName::Value,
ICalendarParameterValue::Value(ICalendarValueType::Date),
);
(PartialDateTime::from_date_timestamp(day), vec![param])
} else {
(PartialDateTime::from_utc_timestamp(t.timestamp()), vec![])
};
ICalendarEntry {
name,
params,
values: vec![ICalendarValue::PartialDateTime(Box::new(value))],
}
};
c.entries.push(when(ICalendarProperty::Dtstart, x.start));
if src.has_property(&ICalendarProperty::Dtend) {
c.entries.push(when(ICalendarProperty::Dtend, x.end));
}
if src.has_property(&ICalendarProperty::Due) {
c.entries.push(when(ICalendarProperty::Due, x.end));
}
if let Some(rid) = x.recurrence_id {
c.entries.push(when(ICalendarProperty::RecurrenceId, rid));
}
out[0].component_ids.push(at);
}
Ok(ICalendar { components: out })
}
/// The masters, and only the overrides that affect `range` (RFC 4791,
/// 9.6.6).
fn limit_recurrence(cal: &ICalendar, range: &TimeRange, floating: &Zone) -> ICalendar {
let exp = expand(cal, range.clone(), floating.clone());
let used: HashSet<usize> = exp.instances.iter().map(|x| x.component).collect();
let mut out = vec![root(cal)];
for &i in cal.components.first().map_or(&[][..], |r| &r.component_ids) {
let c = &cal.components[i as usize];
let keep = match c.property(&ICalendarProperty::RecurrenceId) {
None => true,
Some(rid) => {
exp.truncated
|| used.contains(&(i as usize))
|| rid.parameter(&ICalendarParameterName::Range).is_some()
}
};
if keep {
let at = copy(cal, i as usize, None, &mut out);
out[0].component_ids.push(at);
}
}
ICalendar { components: out }
}
/// Drops the FREEBUSY periods outside `range` (RFC 4791, 9.6.7).
fn limit_freebusy(cal: &mut ICalendar, range: &TimeRange, floating: &Zone) {
let zones = Zones::new(cal, floating.clone());
for c in cal
.components
.iter_mut()
.filter(|c| c.component_type == ICalendarComponentType::VFreebusy)
{
for e in c
.entries
.iter_mut()
.filter(|e| e.name == ICalendarProperty::Freebusy)
{
let tzid = e.tz_id().map(str::to_string);
e.values.retain(|v| {
period(&zones, tzid.as_deref(), v)
.is_some_and(|(s, e)| s < range.end && e > range.start)
});
}
c.entries
.retain(|e| e.name != ICalendarProperty::Freebusy || !e.values.is_empty());
}
}
/// The VCALENDAR with its properties and no components.
fn root(cal: &ICalendar) -> ICalendarComponent {
ICalendarComponent {
component_type: ICalendarComponentType::VCalendar,
entries: cal
.components
.first()
.map(|c| c.entries.clone())
.unwrap_or_default(),
component_ids: Vec::new(),
}
}
/// Appends component `i` of `src` and its sub-components, narrowed by `sel`,
/// to `out`. Returns the new index.
fn copy(
src: &ICalendar,
i: usize,
sel: Option<&CompSelect>,
out: &mut Vec<ICalendarComponent>,
) -> u32 {
let c = &src.components[i];
let entries = match sel.and_then(|s| s.props.as_ref()) {
None => c.entries.clone(),
Some(props) => c
.entries
.iter()
.filter_map(|e| {
let p = props
.iter()
.find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?;
let mut e = e.clone();
if p.novalue {
e.values.clear();
}
Some(e)
})
.collect(),
};
let at = out.len();
out.push(ICalendarComponent {
component_type: c.component_type.clone(),
entries,
component_ids: Vec::new(),
});
for &child in &c.component_ids {
let child = child as usize;
let child_sel = match sel.map(|s| &s.comps) {
None | Some(None) => None,
Some(Some(list)) => {
let name = src.components[child].component_type.as_str();
match list.iter().find(|s| s.name.eq_ignore_ascii_case(name)) {
Some(s) => Some(s),
None => continue,
}
}
};
let id = copy(src, child, child_sel, out);
out[at].component_ids.push(id);
}
at as u32
}
/// The address-data of one card. The stored text is returned unchanged
/// unless the request narrows it or asks for another version.
pub fn address_data(raw: &str, req: &AddressData) -> String {
if *req == AddressData::default() {
return raw.to_string();
}
let Ok(card) = VCard::parse(raw) else {
return raw.to_string();
};
let version = req.version.or(card.version()).unwrap_or_default();
if req.props.is_none() && Some(version) == card.version() {
return raw.to_string();
}
let entries = match &req.props {
None => card.entries,
Some(props) => card
.entries
.into_iter()
.filter_map(|mut e| {
let p = props
.iter()
.find(|p| p.name.eq_ignore_ascii_case(e.name.as_str()))?;
if p.novalue {
e.values.clear();
}
Some(e)
})
.collect(),
};
let mut out = String::new();
let _ = VCard { entries }.write_to(&mut out, version);
out
}
Apimdav/src/report.rs
@@ -0,0 +1,166 @@
//! REPORT request bodies.
use xmltree::Element;
use crate::filter::{CardFilter, CompFilter, TimeRange, calendar_filter, card_filter, time_range};
use crate::render::{AddressData, CalendarData, address_request, calendar_request};
use crate::xml::{CALDAV, CARDDAV, DAV, Name, Propfind, child, elements, text};
use crate::zone::{self, Zone};
/// Why a REPORT body is refused.
#[derive(Debug, Clone, PartialEq)]
pub enum Refused {
/// Malformed: 400.
Invalid,
/// Understood but not served: 403 naming this precondition.
Condition(Name),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Props {
pub find: Propfind,
/// The request of a `calendar-data` element among the properties.
pub calendar: Option<CalendarData>,
pub address: Option<AddressData>,
}
#[derive(Debug, Clone)]
pub enum Report {
CalendarMultiget {
props: Props,
hrefs: Vec<String>,
},
AddressbookMultiget {
props: Props,
hrefs: Vec<String>,
},
CalendarQuery {
props: Props,
filter: CompFilter,
/// From `timezone` or `timezone-id`, for floating times.
timezone: Option<Zone>,
},
AddressbookQuery {
props: Props,
filter: CardFilter,
limit: Option<usize>,
},
SyncCollection {
/// Empty for the first sync.
token: String,
props: Props,
limit: Option<usize>,
},
FreeBusy(TimeRange),
}
pub fn parse(body: &[u8]) -> Result<Report, Refused> {
let root = Element::parse(body).map_err(|_| Refused::Invalid)?;
let hrefs = || {
elements(&root)
.filter(|e| Name::of(e).is(DAV, "href"))
.map(text)
.collect()
};
let name = Name::of(&root);
Ok(match (name.ns.as_str(), name.local.as_str()) {
(CALDAV, "calendar-multiget") => Report::CalendarMultiget {
props: props(&root)?,
hrefs: hrefs(),
},
(CARDDAV, "addressbook-multiget") => Report::AddressbookMultiget {
props: props(&root)?,
hrefs: hrefs(),
},
(CALDAV, "calendar-query") => {
let filter = child(&root, CALDAV, "filter").ok_or(Refused::Invalid)?;
let timezone = match (
child(&root, CALDAV, "timezone"),
child(&root, CALDAV, "timezone-id"),
) {
(Some(tz), _) => {
Some(zone::from_vtimezone(&text(tz)).ok_or_else(|| {
Refused::Condition(Name::new(CALDAV, "valid-calendar-data"))
})?)
}
(None, Some(id)) => Some(
zone::named(&text(id))
.ok_or_else(|| Refused::Condition(Name::new(CALDAV, "valid-timezone")))?,
),
(None, None) => None,
};
Report::CalendarQuery {
props: props(&root)?,
filter: calendar_filter(filter)?,
timezone,
}
}
(CARDDAV, "addressbook-query") => Report::AddressbookQuery {
props: props(&root)?,
filter: card_filter(child(&root, CARDDAV, "filter").ok_or(Refused::Invalid)?)?,
limit: limit(&root, CARDDAV)?,
},
(DAV, "sync-collection") => {
let level = child(&root, DAV, "sync-level").map(text);
// Collections here hold no collections, so both levels agree.
if !matches!(level.as_deref(), None | Some("1" | "infinite")) {
return Err(Refused::Invalid);
}
Report::SyncCollection {
token: child(&root, DAV, "sync-token")
.map(text)
.unwrap_or_default(),
props: props(&root)?,
limit: limit(&root, DAV)?,
}
}
(CALDAV, "free-busy-query") => {
let range = time_range(child(&root, CALDAV, "time-range").ok_or(Refused::Invalid)?)?;
let open =
chrono::DateTime::<chrono::Utc>::MIN_UTC..chrono::DateTime::<chrono::Utc>::MAX_UTC;
if range.start == open.start || range.end == open.end {
return Err(Refused::Invalid);
}
Report::FreeBusy(range)
}
_ => return Err(Refused::Condition(Name::new(DAV, "supported-report"))),
})
}
/// The requested properties. Without any, only the hrefs come back.
fn props(root: &Element) -> Result<Props, Refused> {
let mut out = Props {
find: Propfind::Prop(Vec::new()),
calendar: None,
address: None,
};
for e in elements(root).filter(|e| Name::of(e).ns == DAV) {
match e.name.as_str() {
"allprop" => out.find = Propfind::AllProp(Vec::new()),
"propname" => out.find = Propfind::PropName,
"prop" => {
let mut names = Vec::new();
for p in elements(e) {
let n = Name::of(p);
if n.is(CALDAV, "calendar-data") {
out.calendar = Some(calendar_request(p)?);
} else if n.is(CARDDAV, "address-data") {
out.address = Some(address_request(p)?);
}
names.push(n);
}
out.find = Propfind::Prop(names);
}
_ => {}
}
}
Ok(out)
}
/// `<limit><nresults>n</nresults></limit>` in the namespace `ns`.
fn limit(root: &Element, ns: &str) -> Result<Option<usize>, Refused> {
child(root, ns, "limit")
.and_then(|l| child(l, ns, "nresults"))
.map(|n| text(n).parse().map_err(|_| Refused::Invalid))
.transpose()
}
Mpimdav/src/xml.rs
@@ -54,7 +54,7 @@ impl Name {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invalid;
#[derive(Debug, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub enum Propfind {
/// With the names an `include` element adds.
AllProp(Vec<Name>),
@@ -176,13 +176,24 @@ pub struct Response {
pub href: String,
/// Status code and the properties that share it.
pub propstats: Vec<(u16, Vec<Element>)>,
/// The status of the whole resource, for a response without properties.
pub status: Option<u16>,
pub error: Option<Element>,
}
impl Response {
pub fn new(href: impl Into<String>) -> Self {
Response {
href: href.into(),
propstats: Vec::new(),
..Default::default()
}
}
pub fn status(href: impl Into<String>, status: u16) -> Self {
Response {
href: href.into(),
status: Some(status),
..Default::default()
}
}
@@ -198,16 +209,31 @@ impl Response {
/// A `<d:multistatus>` body, or another root such as
/// `<c:mkcalendar-response>` holding the same propstats.
pub fn multistatus(root: &Name, responses: &[Response]) -> String {
multistatus_with(root, responses, None)
}
/// A multistatus with `tail`, such as a `<d:sync-token>`, after the
/// responses.
pub fn multistatus_with(root: &Name, responses: &[Response], tail: Option<Element>) -> String {
let body = responses.iter().map(|r| {
let mut children = vec![with_text(el(DAV, "href"), r.href.as_str())];
children.extend(
r.status
.map(|s| with_text(el(DAV, "status"), status_line(s))),
);
children.extend(
r.propstats
.iter()
.map(|(status, props)| propstat(*status, props)),
);
children.extend(
r.error
.iter()
.map(|e| with_children(el(DAV, "error"), [e.clone()])),
);
with_children(el(DAV, "response"), children)
});
let root = with_children(root.element(), body);
let root = with_children(root.element(), body.chain(tail));
document(&root)
}
Mpimdav/src/zone.rs
@@ -109,7 +109,7 @@ impl Zones {
.iter()
.filter(|c| c.component_type == ICalendarComponentType::VTimezone)
.filter_map(|c| {
let id = text(c, &ICalendarProperty::Tzid)?;
let id = text_prop(c, &ICalendarProperty::Tzid)?;
Some((id.to_string(), vtimezone(cal, c, id)?))
});
let mut map = HashMap::new();
@@ -142,6 +142,22 @@ impl Zones {
}
}
/// The zone of a `calendar-timezone` value: a VCALENDAR holding one
/// VTIMEZONE.
pub fn from_vtimezone(text: &str) -> Option<Zone> {
let cal = ICalendar::parse(text).ok()?;
let tz = cal
.components
.iter()
.find(|c| c.component_type == ICalendarComponentType::VTimezone)?;
vtimezone(&cal, tz, text_prop(tz, &ICalendarProperty::Tzid)?)
}
/// The zone a TZID names, without a VTIMEZONE to go by.
pub fn named(tzid: &str) -> Option<Zone> {
by_name(tzid).or_else(|| fixed_guess(tzid))
}
/// An exact IANA name wins over the VTIMEZONE rules: clients send stale
/// rules, and the user means the place. A zone guessed from another name must
/// agree with the rules.
@@ -272,7 +288,7 @@ fn offset(c: &ICalendarComponent, prop: &ICalendarProperty) -> Option<i32> {
Some(if v.tz_minus { -secs } else { secs })
}
fn text<'a>(c: &'a ICalendarComponent, prop: &ICalendarProperty) -> Option<&'a str> {
fn text_prop<'a>(c: &'a ICalendarComponent, prop: &ICalendarProperty) -> Option<&'a str> {
c.property(prop)?.values.first()?.as_text()
}
Apimdav/tests/report.rs
@@ -0,0 +1,263 @@
//! REPORT parsing, query filters, calendar-data rendering and free-busy.
use chrono::{DateTime, Utc};
use pimdav::calcard::icalendar::ICalendar;
use pimdav::calcard::vcard::VCard;
use pimdav::filter::{matches_calendar, matches_card};
use pimdav::freebusy::{Busy, busy, merge};
use pimdav::render::{AddressData, address_data, calendar_data};
use pimdav::report::{Refused, Report, parse};
use pimdav::xml::{CALDAV, Name};
use pimdav::zone::{Zone, named};
fn utc(s: &str) -> DateTime<Utc> {
format!("{s}Z").parse().unwrap()
}
fn cal(body: &str) -> ICalendar {
ICalendar::parse(format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"
))
.unwrap()
}
fn query(filter: &str) -> String {
format!(
r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter>
</c:calendar-query>"#
)
}
/// Whether `body` matches `filter` inside the VCALENDAR comp-filter.
fn hit(body: &str, filter: &str) -> bool {
let Ok(Report::CalendarQuery { filter, .. }) = parse(query(filter).as_bytes()) else {
panic!("bad query {filter}");
};
matches_calendar(&cal(body), &filter, &Zone::Utc)
}
fn range(comp: &str, start: &str, end: &str) -> String {
format!(
r#"<c:comp-filter name="{comp}"><c:time-range start="{start}" end="{end}"/></c:comp-filter>"#
)
}
#[test]
fn todo_time_ranges() {
let day = |comp| range(comp, "20260110T000000Z", "20260111T000000Z");
let todo = |props: &str| format!("BEGIN:VTODO\r\nUID:t\r\n{props}END:VTODO\r\n");
assert!(hit(&todo("DUE:20260110T170000Z\r\n"), &day("VTODO")));
// RFC 4791 counts a DUE on the range end as inside.
assert!(hit(&todo("DUE:20260111T000000Z\r\n"), &day("VTODO")));
assert!(!hit(&todo("DUE:20260111T000001Z\r\n"), &day("VTODO")));
assert!(hit(&todo("COMPLETED:20260110T100000Z\r\n"), &day("VTODO")));
assert!(hit(&todo("CREATED:20260101T000000Z\r\n"), &day("VTODO")));
assert!(!hit(&todo("CREATED:20260201T000000Z\r\n"), &day("VTODO")));
assert!(hit(&todo(""), &day("VTODO")));
// DTSTART with DUE spanning the range.
assert!(hit(
&todo("DTSTART:20260101T000000Z\r\nDUE:20260201T000000Z\r\n"),
&day("VTODO")
));
assert!(!hit(&todo("DTSTART:20260111T000000Z\r\n"), &day("VTODO")));
}
#[test]
fn alarms_fire_per_instance() {
let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY\r\n\
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n";
let alarm = |start, end| {
format!(
r#"<c:comp-filter name="VEVENT">{}</c:comp-filter>"#,
range("VALARM", start, end)
)
};
assert!(hit(body, &alarm("20260110T084000Z", "20260110T085000Z")));
assert!(!hit(body, &alarm("20260110T085000Z", "20260110T090000Z")));
let from_end = body.replace("TRIGGER:-PT15M", "TRIGGER;RELATED=END:PT5M");
assert!(hit(
&from_end,
&alarm("20260110T100000Z", "20260110T101000Z")
));
}
#[test]
fn freebusy_components_and_properties() {
let fb = "BEGIN:VFREEBUSY\r\nUID:f\r\nDTSTART:20260101T000000Z\r\nDTEND:20260102T000000Z\r\n\
FREEBUSY:20260101T100000Z/PT1H\r\nEND:VFREEBUSY\r\n";
assert!(hit(
fb,
&range("VFREEBUSY", "20260101T103000Z", "20260101T120000Z")
));
assert!(!hit(
fb,
&range("VFREEBUSY", "20260101T120000Z", "20260101T130000Z")
));
let event = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260101T100000\r\n\
ATTENDEE;PARTSTAT=ACCEPTED;CN=\"Bob, B\":mailto:bob@example.com\r\nEND:VEVENT\r\n";
let prop = |inner: &str| format!(r#"<c:comp-filter name="VEVENT">{inner}</c:comp-filter>"#);
let partstat = r#"<c:prop-filter name="ATTENDEE"><c:param-filter name="PARTSTAT"><c:text-match collation="i;octet">ACCEPTED</c:text-match></c:param-filter></c:prop-filter>"#;
assert!(hit(event, &prop(partstat)));
let cn = r#"<c:prop-filter name="ATTENDEE"><c:param-filter name="CN"><c:text-match>bob, b</c:text-match></c:param-filter></c:prop-filter>"#;
assert!(hit(event, &prop(cn)));
let missing = r#"<c:prop-filter name="LOCATION"><c:is-not-defined/></c:prop-filter>"#;
assert!(hit(event, &prop(missing)));
let dtstart = r#"<c:prop-filter name="DTSTART"><c:time-range start="20260101T090000Z" end="20260101T090001Z"/></c:prop-filter>"#;
assert!(hit(event, &prop(dtstart)));
let no_todo = r#"<c:comp-filter name="VTODO"><c:is-not-defined/></c:comp-filter>"#;
assert!(hit(event, no_todo));
}
#[test]
fn floating_times_use_the_query_zone() {
let body =
"BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20260101T100000\r\nDURATION:PT1H\r\nEND:VEVENT\r\n";
let filter = range("VEVENT", "20260101T090000Z", "20260101T093000Z");
let Ok(Report::CalendarQuery { filter, .. }) = parse(query(&filter).as_bytes()) else {
panic!();
};
assert!(!matches_calendar(&cal(body), &filter, &Zone::Utc));
let berlin = named("Europe/Berlin").unwrap();
assert!(matches_calendar(&cal(body), &filter, &berlin));
}
#[test]
fn report_bodies() {
let body = br#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:sync-level>1</d:sync-level>
<d:limit><d:nresults>5</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
let Ok(Report::SyncCollection { token, limit, .. }) = parse(body) else {
panic!();
};
assert_eq!((token.as_str(), limit), ("", Some(5)));
let body = br#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:filter><c:comp-filter name="VEVENT"/></c:filter></c:calendar-query>"#;
assert_eq!(
parse(body).unwrap_err(),
Refused::Condition(Name::new(CALDAV, "valid-filter"))
);
let body = br#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:timezone-id>Nowhere/Else</c:timezone-id><c:filter><c:comp-filter name="VCALENDAR"/></c:filter></c:calendar-query>"#;
assert_eq!(
parse(body).unwrap_err(),
Refused::Condition(Name::new(CALDAV, "valid-timezone"))
);
let body = br#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z"/></c:free-busy-query>"#;
assert_eq!(parse(body).unwrap_err(), Refused::Invalid);
assert!(matches!(
parse(b"<x:nope xmlns:x=\"urn:x\"/>"),
Err(Refused::Condition(_))
));
}
#[test]
fn expand_keeps_dates_and_limit_keeps_used_overrides() {
let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:d\r\nDTSTART;VALUE=DATE:20260101\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260102\r\nDTSTART;VALUE=DATE:20260102\r\nSUMMARY:moved\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260104\r\nDTSTART;VALUE=DATE:20260104\r\nSUMMARY:later\r\nEND:VEVENT\r\n\
END: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()
};
let out = calendar_data(
body,
&req(r#"<c:expand start="20260102T000000Z" end="20260104T000000Z"/>"#),
&Zone::Utc,
)
.unwrap();
assert!(out.contains("DTSTART;VALUE=DATE:20260102"), "{out}");
assert!(out.contains("RECURRENCE-ID;VALUE=DATE:20260103"), "{out}");
assert!(!out.contains("RRULE") && !out.contains("20260104"), "{out}");
let out = calendar_data(
body,
&req(r#"<c:limit-recurrence-set start="20260102T000000Z" end="20260103T000000Z"/>"#),
&Zone::Utc,
)
.unwrap();
assert!(
out.contains("RRULE") && out.contains("moved") && !out.contains("later"),
"{out}"
);
}
#[test]
fn address_data_converts_versions() {
let card =
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:Bob\r\nTEL;TYPE=CELL:123\r\nEND:VCARD\r\n";
let same = AddressData::default();
assert_eq!(address_data(card, &same), card);
let v4 = AddressData {
props: None,
version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
};
assert!(address_data(card, &v4).contains("VERSION:4.0"));
}
#[test]
fn card_filters() {
let card = VCard::parse(
"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Zoë\r\nEMAIL:z@example.com\r\nEND:VCARD\r\n",
)
.unwrap();
let parse_filter = |f: &str| {
let xml = format!(
r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav">{f}</card:addressbook-query>"#
);
let Ok(Report::AddressbookQuery { filter, .. }) = parse(xml.as_bytes()) else {
panic!("{f}");
};
filter
};
let f = parse_filter(
r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="starts-with">ZO</card:text-match></card:prop-filter></card:filter>"#,
);
assert!(matches_card(&card, &f));
let f = parse_filter(
r#"<card:filter><card:prop-filter name="FN"><card:text-match collation="i;octet">zoë</card:text-match></card:prop-filter></card:filter>"#,
);
assert!(!matches_card(&card, &f));
let f = parse_filter(
r#"<card:filter test="allof"><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#,
);
assert!(!matches_card(&card, &f));
let f = parse_filter(
r#"<card:filter><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#,
);
assert!(matches_card(&card, &f));
}
#[test]
fn busy_time() {
let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T103000Z\r\nDTEND:20260101T120000Z\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T130000Z\r\nDTEND:20260101T140000Z\r\nSTATUS:TENTATIVE\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n";
let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00");
let got = merge(busy(&cal(body), &range, &Zone::Utc));
let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect();
assert_eq!(
got,
[
(
Busy::Busy,
utc("2026-01-01T10:00:00"),
utc("2026-01-01T12:00:00")
),
(
Busy::Tentative,
utc("2026-01-01T13:00:00"),
utc("2026-01-01T13:30:00")
),
]
);
}
Mserver/src/api/pim.rs
@@ -19,11 +19,16 @@ use axum::http::header::{ALLOW, CONTENT_TYPE, ETAG, LOCATION};
use axum::http::{HeaderMap, Method, Request, Response, StatusCode};
use axum::response::IntoResponse;
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use pimdav::object;
use pimdav::calcard::icalendar::ICalendar;
use pimdav::calcard::vcard::VCard;
use pimdav::render::{self, TooManyInstances};
use pimdav::report::{self, Props, Refused, Report};
use pimdav::xml::{
self, APPLE, CALDAV, CALSERVER, CARDDAV, DAV, Name, Propfind, Update, el, hrefs, with_attr,
with_children, with_text,
};
use pimdav::zone::{self, Zone};
use pimdav::{filter, freebusy, object};
use sha2::{Digest, Sha256};
use xmltree::Element;
@@ -81,15 +86,18 @@ pub async fn handle(State(state): State<Arc<AppState>>, req: Request<Body>) -> R
struct Me {
id: i64,
name: String,
/// The user segment of the hrefs: the name as the request spelled it.
/// A client that asked for `/ALICE/` must get hrefs it recognises.
path: String,
}
impl Me {
fn principal(&self) -> String {
format!("{PIM}/principals/{}/", seg(&self.name))
format!("{PIM}/principals/{}/", seg(&self.path))
}
fn home(&self, kind: PimKind) -> String {
format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.name))
format!("{PIM}/{}/{}/", kind_segment(kind), seg(&self.path))
}
fn collection(&self, kind: PimKind, slug: &str) -> String {
@@ -105,7 +113,6 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
let Some(name) = state.db.user_name(user_id).await? else {
return Ok(status(StatusCode::UNAUTHORIZED));
};
let me = Me { id: user_id, name };
let path = req.uri().path().strip_prefix(PIM).unwrap_or_default();
let Some(target) = parse_target(path) else {
return Ok(status(StatusCode::NOT_FOUND));
@@ -114,10 +121,15 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
// access model.
if target
.owner()
.is_some_and(|o| !o.eq_ignore_ascii_case(&me.name))
.is_some_and(|o| !o.eq_ignore_ascii_case(&name))
{
return Ok(status(StatusCode::FORBIDDEN));
}
let me = Me {
id: user_id,
path: target.owner().unwrap_or(&name).to_string(),
name,
};
state.db.pim_ensure_defaults(me.id).await?;
let method = req.method().clone();
@@ -130,7 +142,8 @@ async fn serve(state: &AppState, user_id: i64, req: Request<Body>) -> Reply {
"GET" | "HEAD" => get(state, &me, &target, method == Method::HEAD).await,
"PUT" => put(state, &me, &target, &parts.headers, body).await,
"DELETE" => delete(state, &me, &target, &parts.headers).await,
"REPORT" => Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report"))),
"REPORT" => report(state, &me, &target, body).await,
"MOVE" => move_object(state, &me, &target, &parts.headers).await,
_ => Ok(status(StatusCode::METHOD_NOT_ALLOWED)),
}
}
@@ -227,7 +240,7 @@ fn options() -> Response<Body> {
("dav", "1, 3, calendar-access, addressbook, extended-mkcol"),
(
ALLOW.as_str(),
"OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
"OPTIONS, GET, HEAD, PUT, DELETE, MOVE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT",
),
],
)
@@ -317,28 +330,38 @@ async fn propfind(
let responses: Vec<xml::Response> = list
.into_iter()
.map(|(href, res)| {
let mut r = xml::Response::new(href);
let all = props(me, &res);
match &request {
Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
Propfind::Prop(names) => {
for n in names {
match all.iter().find(|p| Name::of(p) == *n) {
Some(p) => r.push(200, p.clone()),
None => r.push(404, n.element()),
}
}
.map(|(href, res)| select(href, &request, props(me, &res)))
.collect();
Ok(multistatus(&responses, None))
}
/// The response for one resource: the requested ones of `all`, and 404 for
/// those it lacks.
fn select(href: String, request: &Propfind, all: Vec<Element>) -> xml::Response {
let mut r = xml::Response::new(href);
match request {
Propfind::AllProp(_) => all.into_iter().for_each(|p| r.push(200, p)),
Propfind::PropName => all.iter().for_each(|p| r.push(200, Name::of(p).element())),
Propfind::Prop(names) => {
for n in names {
match all.iter().find(|p| Name::of(p) == *n) {
Some(p) => r.push(200, p.clone()),
None => r.push(404, n.element()),
}
}
r
})
.collect();
Ok(xml_response(
}
}
if r.propstats.is_empty() {
r.status = Some(200);
}
r
}
fn multistatus(responses: &[xml::Response], tail: Option<Element>) -> Response<Body> {
xml_response(
StatusCode::MULTI_STATUS,
xml::multistatus(&Name::new(DAV, "multistatus"), &responses),
))
xml::multistatus_with(&Name::new(DAV, "multistatus"), responses, tail),
)
}
/// Every live property of a resource, with its value.
@@ -396,8 +419,7 @@ fn props(me: &Me, res: &Res) -> Vec<Element> {
resourcetype(&[(DAV, "collection"), types]),
href_prop(DAV, "owner", &me.principal()),
privileges(),
// Empty until the REPORTs exist.
el(DAV, "supported-report-set"),
supported_reports(*kind),
text(CALSERVER, "getctag", &format!("{}-{}", c.id, c.seq)),
text(DAV, "sync-token", &sync_token(c)),
text(
@@ -474,6 +496,31 @@ fn props(me: &Me, res: &Res) -> Vec<Element> {
out
}
fn supported_reports(kind: PimKind) -> Element {
let reports: &[(&str, &str)] = match kind {
PimKind::Calendar => &[
(CALDAV, "calendar-multiget"),
(CALDAV, "calendar-query"),
(CALDAV, "free-busy-query"),
(DAV, "sync-collection"),
],
PimKind::AddressBook => &[
(CARDDAV, "addressbook-multiget"),
(CARDDAV, "addressbook-query"),
(DAV, "sync-collection"),
],
};
with_children(
el(DAV, "supported-report-set"),
reports.iter().map(|(ns, local)| {
with_children(
el(DAV, "supported-report"),
[with_children(el(DAV, "report"), [el(ns, local)])],
)
}),
)
}
fn privileges() -> Element {
let names = [
"all",
@@ -542,10 +589,7 @@ async fn proppatch(state: &AppState, me: &Me, target: &Target, body: Body) -> Re
for (code, prop) in results {
r.push(code, prop);
}
Ok(xml_response(
StatusCode::MULTI_STATUS,
xml::multistatus(&Name::new(DAV, "multistatus"), &[r]),
))
Ok(multistatus(&[r], None))
}
async fn mkcol(state: &AppState, me: &Me, target: &Target, method: &str, body: Body) -> Reply {
@@ -577,7 +621,7 @@ async fn mkcol(state: &AppState, me: &Me, target: &Target, method: &str, body: B
let mut col = PimCollection {
slug: slug.clone(),
components: match kind {
PimKind::Calendar => "VEVENT,VTODO".to_string(),
PimKind::Calendar => "VEVENT,VTODO,VJOURNAL".to_string(),
PimKind::AddressBook => String::new(),
},
..Default::default()
@@ -836,3 +880,316 @@ fn precondition(headers: &HeaderMap) -> Precondition {
if_none_match: header("if-none-match"),
}
}
// ---------------------------------------------------------------------------
// REPORT
// ---------------------------------------------------------------------------
async fn report(state: &AppState, me: &Me, target: &Target, body: Body) -> Reply {
let Some(body) = read_body(body, MAX_XML_SIZE).await else {
return Ok(status(StatusCode::PAYLOAD_TOO_LARGE));
};
let report = match report::parse(&body) {
Ok(r) => r,
Err(Refused::Invalid) => return Ok(status(StatusCode::BAD_REQUEST)),
Err(Refused::Condition(c)) => return Ok(error(StatusCode::FORBIDDEN, c.element())),
};
let unsupported = || Ok(error(StatusCode::FORBIDDEN, el(DAV, "supported-report")));
let Target::Collection(kind, _, slug) = target else {
return unsupported();
};
let calendar_report = matches!(
report,
Report::CalendarMultiget { .. } | Report::CalendarQuery { .. } | Report::FreeBusy(_)
);
let card_report = matches!(
report,
Report::AddressbookMultiget { .. } | Report::AddressbookQuery { .. }
);
if (calendar_report && *kind != PimKind::Calendar)
|| (card_report && *kind != PimKind::AddressBook)
{
return unsupported();
}
let Some(col) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let floating = col
.timezone
.as_deref()
.and_then(zone::from_vtimezone)
.unwrap_or(Zone::Utc);
let out = Out {
me,
kind: *kind,
col: &col,
};
match report {
Report::CalendarMultiget { props, hrefs }
| Report::AddressbookMultiget { props, hrefs } => {
let mut responses = Vec::new();
for href in hrefs {
let found = match own_object(me, *kind, &href) {
Some((slug, name)) if slug == col.slug => {
state.db.pim_object(col.id, &name).await?
}
_ => None,
};
responses.push(match found {
// The href as the client wrote it, so it can match it.
Some((o, data)) => match out.object(&o, &data, &props, &floating) {
Ok(r) => xml::Response { href, ..r },
Err(TooManyInstances) => return Ok(too_many()),
},
None => xml::Response::status(href, 404),
});
}
Ok(multistatus(&responses, None))
}
Report::CalendarQuery {
props,
filter,
timezone,
} => {
let floating = timezone.unwrap_or(floating);
let mut responses = Vec::new();
for (o, data) in state.db.pim_objects_with_data(col.id).await? {
let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) else {
continue;
};
if filter::matches_calendar(&cal, &filter, &floating) {
match out.object(&o, &data, &props, &floating) {
Ok(r) => responses.push(r),
Err(TooManyInstances) => return Ok(too_many()),
}
}
}
Ok(multistatus(&responses, None))
}
Report::AddressbookQuery {
props,
filter,
limit,
} => {
let mut responses = Vec::new();
let mut truncated = false;
for (o, data) in state.db.pim_objects_with_data(col.id).await? {
let Ok(card) = VCard::parse(String::from_utf8_lossy(&data).as_ref()) else {
continue;
};
if !filter::matches_card(&card, &filter) {
continue;
}
if limit.is_some_and(|n| responses.len() >= n) {
truncated = true;
break;
}
if let Ok(r) = out.object(&o, &data, &props, &floating) {
responses.push(r);
}
}
if truncated {
responses.push(out.over_limit());
}
Ok(multistatus(&responses, None))
}
Report::SyncCollection {
token,
props,
limit,
} => {
let since = if token.is_empty() {
None
} else {
match parse_sync_token(&token) {
Some((id, seq)) if id == col.id && seq <= col.seq => Some(seq),
_ => return Ok(error(StatusCode::FORBIDDEN, el(DAV, "valid-sync-token"))),
}
};
let mut changes = state.db.pim_changes(col.id, since).await?;
let truncated = limit.is_some_and(|n| changes.len() > n);
if let Some(n) = limit {
changes.truncate(n);
}
// A truncated answer hands out the token of its last change, so
// the next sync resumes after it.
let seq = match (truncated, changes.last()) {
(true, Some((_, s, _))) => *s,
(_, last) => col.seq.max(last.map_or(0, |(_, s, _)| *s)),
};
let mut responses = Vec::new();
for (name, _, deleted) in changes {
let href = me.object(*kind, &col.slug, &name);
let found = match deleted {
true => None,
false => state.db.pim_object(col.id, &name).await?,
};
responses.push(match found {
Some((o, data)) => match out.object(&o, &data, &props, &floating) {
Ok(r) => r,
Err(TooManyInstances) => return Ok(too_many()),
},
None => xml::Response::status(href, 404),
});
}
if truncated {
responses.push(out.over_limit());
}
let token = format!("urn:fbng:sync:{}-{seq}", col.id);
Ok(multistatus(
&responses,
Some(with_text(el(DAV, "sync-token"), token)),
))
}
Report::FreeBusy(range) => {
let mut busy = Vec::new();
for (_, data) in state.db.pim_objects_with_data(col.id).await? {
if let Ok(cal) = ICalendar::parse(String::from_utf8_lossy(&data).as_ref()) {
// ponytail: one period per instance, so a long range over
// a frequent series makes a long answer.
busy.extend(freebusy::busy(&cal, &range, &floating));
}
}
let body = freebusy::vfreebusy(&freebusy::merge(busy), &range, chrono::Utc::now());
Ok((
StatusCode::OK,
[(CONTENT_TYPE, "text/calendar; charset=utf-8")],
body,
)
.into_response())
}
}
}
/// What a REPORT answer about one collection needs.
struct Out<'a> {
me: &'a Me,
kind: PimKind,
col: &'a PimCollection,
}
impl Out<'_> {
fn object(
&self,
o: &PimObject,
data: &[u8],
props: &Props,
floating: &Zone,
) -> Result<xml::Response, TooManyInstances> {
let mut all = self::props(self.me, &Res::Object(self.kind, o.clone()));
let raw = String::from_utf8_lossy(data);
if let Some(req) = &props.calendar {
let text = render::calendar_data(&raw, req, floating)?;
all.push(with_text(el(CALDAV, "calendar-data"), text));
}
if let Some(req) = &props.address {
all.push(with_text(
el(CARDDAV, "address-data"),
render::address_data(&raw, req),
));
}
let href = self.me.object(self.kind, &self.col.slug, &o.name);
Ok(select(href, &props.find, all))
}
/// The response a query or sync adds when a client limit cut it short.
fn over_limit(&self) -> xml::Response {
let mut r = xml::Response::status(self.me.collection(self.kind, &self.col.slug), 507);
r.error = Some(el(DAV, "number-of-matches-within-limits"));
r
}
}
fn too_many() -> Response<Body> {
error(StatusCode::FORBIDDEN, el(CALDAV, "max-instances"))
}
/// `(collection id, seq)` of a token [`sync_token`] made.
fn parse_sync_token(token: &str) -> Option<(i64, i64)> {
let (id, seq) = token.strip_prefix("urn:fbng:sync:")?.split_once('-')?;
Some((id.parse().ok()?, seq.parse().ok()?))
}
/// `(collection slug, object name)` of an href to one of the user's own
/// objects of `kind`. Takes a path or a full URL.
fn own_object(me: &Me, kind: PimKind, href: &str) -> Option<(String, String)> {
let path = match href.starts_with('/') {
true => href.to_string(),
false => href.parse::<axum::http::Uri>().ok()?.path().to_string(),
};
match parse_target(path.strip_prefix(PIM)?)? {
Target::Object(k, owner, slug, name)
if k == kind && owner.eq_ignore_ascii_case(&me.name) =>
{
Some((slug, name))
}
_ => None,
}
}
// ---------------------------------------------------------------------------
// MOVE
// ---------------------------------------------------------------------------
async fn move_object(state: &AppState, me: &Me, target: &Target, headers: &HeaderMap) -> Reply {
let Target::Object(kind, _, slug, name) = target else {
return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
};
let destination = headers.get("destination").and_then(|v| v.to_str().ok());
let Some((to_slug, to_name)) = destination.and_then(|d| own_object(me, *kind, d)) else {
return Ok(status(StatusCode::FORBIDDEN));
};
if (&to_slug, &to_name) == (slug, name) {
return Ok(status(StatusCode::FORBIDDEN));
}
let Some(from) = state.db.pim_collection(me.id, *kind, slug).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
let Some(to) = state.db.pim_collection(me.id, *kind, &to_slug).await? else {
return Ok(status(StatusCode::CONFLICT));
};
let Some((obj, _)) = state.db.pim_object(from.id, name).await? else {
return Ok(status(StatusCode::NOT_FOUND));
};
if *kind == PimKind::Calendar && !to.components.split(',').any(|c| c == obj.component) {
return Ok(error(
StatusCode::FORBIDDEN,
el(CALDAV, "supported-calendar-component"),
));
}
let overwrite = headers.get("overwrite").and_then(|v| v.to_str().ok()) != Some("F");
Ok(
match state
.db
.pim_move_object(
from.id,
name,
to.id,
&to_name,
overwrite,
&precondition(headers),
)
.await?
{
PimWrite::Created => status(StatusCode::CREATED),
PimWrite::Updated => status(StatusCode::NO_CONTENT),
PimWrite::NotFound => status(StatusCode::NOT_FOUND),
PimWrite::PreconditionFailed => status(StatusCode::PRECONDITION_FAILED),
PimWrite::UidConflict(holder) => error(
StatusCode::FORBIDDEN,
with_children(
el(
match kind {
PimKind::Calendar => CALDAV,
PimKind::AddressBook => CARDDAV,
},
"no-uid-conflict",
),
hrefs([me.object(*kind, &to_slug, &holder).as_str()]),
),
),
PimWrite::Deleted => status(StatusCode::INTERNAL_SERVER_ERROR),
},
)
}
Mserver/src/db.rs
@@ -1271,7 +1271,13 @@ impl Db {
WHERE NOT EXISTS (SELECT 1 FROM pim_collections WHERE user_id = ?1 AND kind = ?2)",
)?;
let now = now();
stmt.execute(params![user_id, "cal", "Calendar", "VEVENT,VTODO", now])?;
stmt.execute(params![
user_id,
"cal",
"Calendar",
"VEVENT,VTODO,VJOURNAL",
now
])?;
stmt.execute(params![user_id, "card", "Contacts", "", now])?;
Ok(())
}
@@ -1382,6 +1388,102 @@ impl Db {
.optional()
}
pub async fn pim_objects_with_data(
&self,
collection_id: i64,
) -> DbResult<Vec<(PimObject, Vec<u8>)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(&format!(
"SELECT {PIM_OBJECT_COLS}, data FROM pim_objects WHERE collection_id = ?1 ORDER BY name"
))?;
stmt.query_map([collection_id], |r| Ok((map_pim_object(r)?, r.get(6)?)))?
.collect()
}
/// `(name, seq, deleted)` of the members changed after `since`, oldest
/// first. Without `since`, the members that exist.
pub async fn pim_changes(
&self,
collection_id: i64,
since: Option<i64>,
) -> DbResult<Vec<(String, i64, bool)>> {
let c = self.0.lock().await;
let mut stmt = c.prepare_cached(
"SELECT name, seq, deleted != 0 FROM pim_changes
WHERE collection_id = ?1 AND (?2 IS NULL AND deleted = 0 OR seq > ?2)
ORDER BY seq",
)?;
stmt.query_map(params![collection_id, since], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})?
.collect()
}
/// Moves an object to `to_name` in collection `to`, which may be the
/// same collection. `Updated` means an object at the destination was
/// replaced.
pub async fn pim_move_object(
&self,
from: i64,
name: &str,
to: i64,
to_name: &str,
overwrite: bool,
cond: &Precondition,
) -> DbResult<PimWrite> {
let mut c = self.0.lock().await;
let tx = c.transaction()?;
let source: Option<(String, String)> = tx
.query_row(
"SELECT etag, uid FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![from, name],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((etag, uid)) = source else {
return Ok(PimWrite::NotFound);
};
if !cond.allows(Some(&etag)) {
return Ok(PimWrite::PreconditionFailed);
}
let taken: bool = tx.query_row(
"SELECT EXISTS (SELECT 1 FROM pim_objects WHERE collection_id = ?1 AND name = ?2)",
params![to, to_name],
|r| r.get(0),
)?;
if taken && !overwrite {
return Ok(PimWrite::PreconditionFailed);
}
let holder: Option<String> = tx
.query_row(
"SELECT name FROM pim_objects WHERE collection_id = ?1 AND uid = ?2
AND name != ?3 AND NOT (collection_id = ?4 AND name = ?5)",
params![to, uid, to_name, from, name],
|r| r.get(0),
)
.optional()?;
if let Some(holder) = holder {
return Ok(PimWrite::UidConflict(holder));
}
tx.execute(
"DELETE FROM pim_objects WHERE collection_id = ?1 AND name = ?2",
params![to, to_name],
)?;
tx.execute(
"UPDATE pim_objects SET collection_id = ?3, name = ?4, modified_at = ?5
WHERE collection_id = ?1 AND name = ?2",
params![from, name, to, to_name, now()],
)?;
record_pim_change(&tx, from, name, true)?;
record_pim_change(&tx, to, to_name, false)?;
tx.commit()?;
Ok(if taken {
PimWrite::Updated
} else {
PimWrite::Created
})
}
/// Stores an object under `obj.name`. The precondition and the UID check
/// run in the same transaction as the write.
pub async fn pim_put_object(
Mserver/tests/api_pim.rs
@@ -222,7 +222,7 @@ async fn homes_list_the_default_collections() {
let comps: Vec<_> = xml::elements(&comps)
.map(|c| c.attributes["name"].clone())
.collect();
assert_eq!(comps, ["VEVENT", "VTODO"]);
assert_eq!(comps, ["VEVENT", "VTODO", "VJOURNAL"]);
assert!(prop_text(&ms, CAL, CALSERVER, "getctag").is_some());
assert!(
prop_text(&ms, CAL, DAV, "sync-token")
@@ -447,14 +447,14 @@ async fn calendar_objects() {
assert!(error_condition(&r).is(CALDAV, "no-uid-conflict"));
assert!(r.text().contains(&obj), "{}", r.text());
let journal = event("j", "x").replace("VEVENT", "VJOURNAL");
let freebusy = event("j", "x").replace("VEVENT", "VFREEBUSY");
let cases = [
("not a calendar".to_string(), "valid-calendar-data"),
(
event("m", "x").replace("VERSION:2.0", "VERSION:2.0\r\nMETHOD:PUBLISH"),
"valid-calendar-object-resource",
),
(journal, "supported-calendar-component"),
(freebusy, "supported-calendar-component"),
];
for (body, cond) in cases {
let r = req(&env, "PUT", &format!("{CAL}x.ics"), &auth, &[], &body).await;
@@ -489,7 +489,7 @@ async fn calendar_objects() {
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = req(&env, "REPORT", CAL, &auth, &[], "").await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
@@ -553,3 +553,468 @@ async fn deleting_a_user_deletes_their_collections() {
.is_empty()
);
}
// ---------------------------------------------------------------------------
// REPORT and MOVE
// ---------------------------------------------------------------------------
/// `(href, status of the response itself)` of every response.
fn statuses(r: &Resp) -> Vec<(String, Option<u16>)> {
let root = Element::parse(r.body.as_slice()).unwrap();
xml::elements(&root)
.filter(|e| Name::of(e).is(DAV, "response"))
.map(|resp| {
let href = xml::text(xml::child(resp, DAV, "href").unwrap());
let code = xml::child(resp, DAV, "status")
.map(|s| xml::text(s).split(' ').nth(1).unwrap().parse().unwrap());
(href, code)
})
.collect()
}
fn sync_token_of(r: &Resp) -> String {
let root = Element::parse(r.body.as_slice()).unwrap();
xml::text(xml::child(&root, DAV, "sync-token").unwrap())
}
fn ics(body: &str) -> String {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
}
const LUNCH: &str = "BEGIN:VEVENT\r\nUID:lunch\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nSUMMARY:Team Lunch\r\nEND:VEVENT\r\n";
const WEEKLY: &str = "BEGIN:VEVENT\r\nUID:weekly\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/Berlin:20251201T090000\r\nDTEND;TZID=Europe/Berlin:20251201T093000\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n";
const TODO: &str = "BEGIN:VTODO\r\nUID:todo\r\nDTSTAMP:20260101T000000Z\r\nDUE:20260110T170000Z\r\nSUMMARY:Taxes\r\nEND:VTODO\r\n";
async fn put(env: &Env, auth: &str, path: &str, body: &str) {
let r = req(env, "PUT", path, auth, &[], body).await;
assert!(r.status.is_success(), "{path}: {}", r.text());
}
fn query(filter: &str, data: &str) -> String {
format!(
r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/>{data}</d:prop>
<c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter>
</c:calendar-query>"#
)
}
fn hrefs_in(r: &Resp) -> Vec<String> {
let mut h: Vec<_> = statuses(r).into_iter().map(|(h, _)| h).collect();
h.sort();
h
}
#[tokio::test]
async fn calendar_reports() {
let (env, auth) = setup().await;
put(&env, &auth, &format!("{CAL}lunch.ics"), &ics(LUNCH)).await;
put(&env, &auth, &format!("{CAL}weekly.ics"), &ics(WEEKLY)).await;
put(&env, &auth, &format!("{CAL}todo.ics"), &ics(TODO)).await;
let r = req(
&env,
"PROPFIND",
CAL,
&auth,
&[("depth", "0")],
&propfind_body(&[(DAV, "supported-report-set")]),
)
.await;
let reports = prop(&parse_multistatus(&r), CAL, DAV, "supported-report-set").unwrap();
assert_eq!(xml::elements(&reports).count(), 4);
// multiget: stored bytes back, a miss as 404.
let body = format!(
r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<d:href>{CAL}lunch.ics</d:href><d:href>{CAL}gone.ics</d:href>
</c:calendar-multiget>"#
);
let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], &body).await;
let ms = parse_multistatus(&r);
let lunch = format!("{CAL}lunch.ics");
// XML parsing turns CRLF into LF.
let data = prop_text(&ms, &lunch, CALDAV, "calendar-data").unwrap();
assert_eq!(data, ics(LUNCH).replace("\r\n", "\n").trim());
assert!(statuses(&r).contains(&(format!("{CAL}gone.ics"), Some(404))));
// Time range: the Monday 2026-01-05 holds only an instance of the weekly
// series, which started a month earlier in Berlin time.
let range = r#"<c:comp-filter name="VEVENT"><c:time-range start="20260105T000000Z" end="20260106T000000Z"/></c:comp-filter>"#;
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[("depth", "1")],
&query(range, ""),
)
.await;
assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
// The same with expand: one instance in UTC, without the RRULE.
let expand = r#"<c:calendar-data><c:expand start="20260105T000000Z" end="20260106T000000Z"/></c:calendar-data>"#;
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[("depth", "1")],
&query(range, expand),
)
.await;
let data = prop_text(
&parse_multistatus(&r),
&format!("{CAL}weekly.ics"),
CALDAV,
"calendar-data",
)
.unwrap();
assert!(data.contains("DTSTART:20260105T080000Z"), "{data}");
assert!(data.contains("RECURRENCE-ID:20260105T080000Z"), "{data}");
assert!(!data.contains("RRULE"), "{data}");
// text-match folds ASCII case by default, and negates on request.
let text = r#"<c:comp-filter name="VEVENT"><c:prop-filter name="SUMMARY"><c:text-match>team lunch</c:text-match></c:prop-filter></c:comp-filter>"#;
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[("depth", "1")],
&query(text, ""),
)
.await;
assert_eq!(hrefs_in(&r), std::slice::from_ref(&lunch));
let negated = text.replace("<c:text-match>", r#"<c:text-match negate-condition="yes">"#);
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[("depth", "1")],
&query(&negated, ""),
)
.await;
assert_eq!(hrefs_in(&r), [format!("{CAL}weekly.ics")]);
let odd = text.replace("<c:text-match>", r#"<c:text-match collation="i;klingon">"#);
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[("depth", "1")],
&query(&odd, ""),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert_eq!(
error_condition(&r),
Name::new(CALDAV, "supported-collation")
);
// A VTODO with only DUE matches the range that holds DUE.
let todo = r#"<c:comp-filter name="VTODO"><c:time-range start="20260110T000000Z" end="20260111T000000Z"/></c:comp-filter>"#;
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[("depth", "1")],
&query(todo, ""),
)
.await;
assert_eq!(hrefs_in(&r), [format!("{CAL}todo.ics")]);
// Only the components asked for.
let comp = r#"<c:calendar-data><c:comp name="VCALENDAR"><c:comp name="VEVENT"><c:prop name="SUMMARY"/></c:comp></c:comp></c:calendar-data>"#;
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[("depth", "1")],
&query(text, comp),
)
.await;
let data = prop_text(&parse_multistatus(&r), &lunch, CALDAV, "calendar-data").unwrap();
assert!(
data.contains("SUMMARY:Team Lunch")
&& !data.contains("DTSTART")
&& !data.contains("VERSION"),
"{data}"
);
let fb = r#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z" end="20260106T000000Z"/></c:free-busy-query>"#;
let r = req(&env, "REPORT", CAL, &auth, &[("depth", "1")], fb).await;
assert_eq!(r.status, StatusCode::OK);
assert!(
r.header("content-type")
.unwrap()
.starts_with("text/calendar")
);
assert!(
r.text()
.contains("FREEBUSY;FBTYPE=BUSY:20260105T080000Z/20260105T083000Z"),
"{}",
r.text()
);
assert!(
r.text()
.contains("FREEBUSY;FBTYPE=BUSY:20260101T120000Z/20260101T130000Z"),
"{}",
r.text()
);
// An address book report on a calendar is refused.
let r = req(&env, "REPORT", CAL, &auth, &[], r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav"><card:filter/></card:addressbook-query>"#).await;
assert_eq!(error_condition(&r), Name::new(DAV, "supported-report"));
}
#[tokio::test]
async fn sync_collection() {
let (env, auth) = setup().await;
let sync = |token: &str, limit: &str| {
format!(
r#"<d:sync-collection xmlns:d="DAV:"><d:sync-token>{token}</d:sync-token><d:sync-level>1</d:sync-level>{limit}<d:prop><d:getetag/></d:prop></d:sync-collection>"#
)
};
put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
let r = req(&env, "REPORT", CAL, &auth, &[], &sync("", "")).await;
assert_eq!(hrefs_in(&r), [format!("{CAL}a.ics"), format!("{CAL}b.ics")]);
let token = sync_token_of(&r);
put(&env, &auth, &format!("{CAL}c.ics"), &event("c", "C")).await;
put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A2")).await;
let r = req(&env, "DELETE", &format!("{CAL}b.ics"), &auth, &[], "").await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, "")).await;
let mut got = statuses(&r);
got.sort();
assert_eq!(
got,
[
(format!("{CAL}a.ics"), None),
(format!("{CAL}b.ics"), Some(404)),
(format!("{CAL}c.ics"), None),
]
);
let latest = sync_token_of(&r);
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&latest, "")).await;
assert!(statuses(&r).is_empty());
// A limit hands out the token of the last change it returned.
let limit = "<d:limit><d:nresults>1</d:nresults></d:limit>";
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(&token, limit)).await;
let got = statuses(&r);
assert_eq!(got.len(), 2);
assert_eq!(got[1], (CAL.to_string(), Some(507)));
let r = req(
&env,
"REPORT",
CAL,
&auth,
&[],
&sync(&sync_token_of(&r), ""),
)
.await;
assert_eq!(statuses(&r).len(), 2);
for bad in ["urn:fbng:sync:999-1", "nonsense", &format!("{latest}0")] {
let r = req(&env, "REPORT", CAL, &auth, &[], &sync(bad, "")).await;
assert_eq!(
error_condition(&r),
Name::new(DAV, "valid-sync-token"),
"{bad}"
);
}
}
#[tokio::test]
async fn addressbook_reports() {
let (env, auth) = setup().await;
let card = |uid: &str, name: &str, mail: &str| {
format!(
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{name}\r\nEMAIL;TYPE=WORK:{mail}\r\nEND:VCARD\r\n"
)
};
put(
&env,
&auth,
&format!("{BOOK}bob.vcf"),
&card("bob", "Bob Builder", "bob@example.com"),
)
.await;
put(
&env,
&auth,
&format!("{BOOK}ann.vcf"),
&card("ann", "Ann Äpfel", "ann@example.org"),
)
.await;
let query = |filter: &str, data: &str| {
format!(
r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/>{data}</d:prop>{filter}</card:addressbook-query>"#
)
};
let email = r#"<card:filter><card:prop-filter name="EMAIL"><card:text-match match-type="ends-with">.ORG</card:text-match></card:prop-filter></card:filter>"#;
let r = req(&env, "REPORT", BOOK, &auth, &[], &query(email, "")).await;
assert_eq!(hrefs_in(&r), [format!("{BOOK}ann.vcf")]);
// Unicode case folding is the CardDAV default.
let fname = r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="equals">ann äpfel</card:text-match></card:prop-filter></card:filter>"#;
let r = req(
&env,
"REPORT",
BOOK,
&auth,
&[],
&query(
fname,
"<card:address-data><card:prop name=\"FN\"/></card:address-data>",
),
)
.await;
let data = prop_text(
&parse_multistatus(&r),
&format!("{BOOK}ann.vcf"),
CARDDAV,
"address-data",
)
.unwrap();
assert!(
data.contains("Ann Äpfel") && !data.contains("EMAIL"),
"{data}"
);
let param = r#"<card:filter test="allof"><card:prop-filter name="EMAIL"><card:param-filter name="TYPE"><card:text-match match-type="equals">work</card:text-match></card:param-filter></card:prop-filter><card:prop-filter name="NICKNAME"><card:is-not-defined/></card:prop-filter></card:filter>"#;
let r = req(&env, "REPORT", BOOK, &auth, &[], &query(param, "")).await;
assert_eq!(hrefs_in(&r).len(), 2);
let limited = query(
"<card:filter/><card:limit><card:nresults>1</card:nresults></card:limit>",
"",
);
let r = req(&env, "REPORT", BOOK, &auth, &[], &limited).await;
let got = statuses(&r);
assert_eq!(got.len(), 2);
assert_eq!(got[1], (BOOK.to_string(), Some(507)));
let body = format!(
r#"<card:addressbook-multiget xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><card:address-data/></d:prop><d:href>{BOOK}bob.vcf</d:href></card:addressbook-multiget>"#
);
let r = req(&env, "REPORT", BOOK, &auth, &[], &body).await;
let data = prop_text(
&parse_multistatus(&r),
&format!("{BOOK}bob.vcf"),
CARDDAV,
"address-data",
)
.unwrap();
assert!(data.contains("FN:Bob Builder"));
}
#[tokio::test]
async fn move_objects() {
let (env, auth) = setup().await;
let r = req(&env, "MKCALENDAR", &format!("{HOME}work/"), &auth, &[], "").await;
assert_eq!(r.status, StatusCode::CREATED);
put(&env, &auth, &format!("{CAL}a.ics"), &event("a", "A")).await;
put(&env, &auth, &format!("{CAL}b.ics"), &event("b", "B")).await;
let dest = |p: &str| format!("http://localhost{p}");
let r = req(
&env,
"MOVE",
&format!("{CAL}a.ics"),
&auth,
&[("destination", &dest(&format!("{HOME}work/a.ics")))],
"",
)
.await;
assert_eq!(r.status, StatusCode::CREATED);
assert_eq!(
req(&env, "GET", &format!("{CAL}a.ics"), &auth, &[], "")
.await
.status,
StatusCode::NOT_FOUND
);
assert_eq!(
req(&env, "GET", &format!("{HOME}work/a.ics"), &auth, &[], "")
.await
.text(),
event("a", "A")
);
// Overwrite: F refuses an existing destination.
put(&env, &auth, &format!("{CAL}a2.ics"), &event("a2", "A2")).await;
let r = req(
&env,
"MOVE",
&format!("{CAL}a2.ics"),
&auth,
&[("destination", &format!("{CAL}b.ics")), ("overwrite", "F")],
"",
)
.await;
assert_eq!(r.status, StatusCode::PRECONDITION_FAILED);
let r = req(
&env,
"MOVE",
&format!("{CAL}a2.ics"),
&auth,
&[("destination", &format!("{CAL}b.ics"))],
"",
)
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
// The same UID under another name in the destination.
put(&env, &auth, &format!("{CAL}dup.ics"), &event("a", "again")).await;
let r = req(
&env,
"MOVE",
&format!("{CAL}dup.ics"),
&auth,
&[("destination", &format!("{HOME}work/other.ics"))],
"",
)
.await;
assert_eq!(error_condition(&r), Name::new(CALDAV, "no-uid-conflict"));
// Across kinds is refused.
let r = req(
&env,
"MOVE",
&format!("{CAL}b.ics"),
&auth,
&[("destination", &format!("{BOOK}b.vcf"))],
"",
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn hrefs_follow_the_requested_spelling() {
let (env, auth) = setup().await;
let body = propfind_body(&[(DAV, "displayname")]);
let r = req(
&env,
"PROPFIND",
"/pim/calendars/ALICE/",
&auth,
&[("depth", "1")],
&body,
)
.await;
let hrefs = hrefs_in(&r);
assert!(
hrefs.iter().all(|h| h.starts_with("/pim/calendars/ALICE/")),
"{hrefs:?}"
);
}