report.rs
⎇
Raw
1//! REPORT parsing, query filters, calendar-data rendering and free-busy.
2
3use chrono::{DateTime, Utc};
4use pimdav::calcard::icalendar::ICalendar;
5use pimdav::calcard::vcard::VCard;
6use pimdav::filter::{matches_calendar, matches_card};
7use pimdav::freebusy::{Busy, busy, merge};
8use pimdav::render::{AddressData, address_data, calendar_data};
9use pimdav::report::{Refused, Report, parse};
10use pimdav::xml::{CALDAV, Name};
11use pimdav::zone::{Zone, named};
12
13fn utc(s: &str) -> DateTime<Utc> {
14 format!("{s}Z").parse().unwrap()
15}
16
17fn cal(body: &str) -> ICalendar {
18 ICalendar::parse(format!(
19 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n"
20 ))
21 .unwrap()
22}
23
24fn query(filter: &str) -> String {
25 format!(
26 r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
27 <d:prop><d:getetag/></d:prop>
28 <c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter>
29 </c:calendar-query>"#
30 )
31}
32
33/// Whether `body` matches `filter` inside the VCALENDAR comp-filter.
34fn hit(body: &str, filter: &str) -> bool {
35 let Ok(Report::CalendarQuery { filter, .. }) = parse(query(filter).as_bytes()) else {
36 panic!("bad query {filter}");
37 };
38 matches_calendar(&cal(body), &filter, &Zone::Utc)
39}
40
41fn range(comp: &str, start: &str, end: &str) -> String {
42 format!(
43 r#"<c:comp-filter name="{comp}"><c:time-range start="{start}" end="{end}"/></c:comp-filter>"#
44 )
45}
46
47#[test]
48fn todo_time_ranges() {
49 let day = |comp| range(comp, "20260110T000000Z", "20260111T000000Z");
50 let todo = |props: &str| format!("BEGIN:VTODO\r\nUID:t\r\n{props}END:VTODO\r\n");
51 assert!(hit(&todo("DUE:20260110T170000Z\r\n"), &day("VTODO")));
52 // RFC 4791 counts a DUE on the range end as inside.
53 assert!(hit(&todo("DUE:20260111T000000Z\r\n"), &day("VTODO")));
54 assert!(!hit(&todo("DUE:20260111T000001Z\r\n"), &day("VTODO")));
55 assert!(hit(&todo("COMPLETED:20260110T100000Z\r\n"), &day("VTODO")));
56 assert!(hit(&todo("CREATED:20260101T000000Z\r\n"), &day("VTODO")));
57 assert!(!hit(&todo("CREATED:20260201T000000Z\r\n"), &day("VTODO")));
58 assert!(hit(&todo(""), &day("VTODO")));
59 // DTSTART with DUE spanning the range.
60 assert!(hit(
61 &todo("DTSTART:20260101T000000Z\r\nDUE:20260201T000000Z\r\n"),
62 &day("VTODO")
63 ));
64 assert!(!hit(&todo("DTSTART:20260111T000000Z\r\n"), &day("VTODO")));
65}
66
67#[test]
68fn alarms_fire_per_instance() {
69 let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY\r\n\
70 BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n";
71 let alarm = |start, end| {
72 format!(
73 r#"<c:comp-filter name="VEVENT">{}</c:comp-filter>"#,
74 range("VALARM", start, end)
75 )
76 };
77 assert!(hit(body, &alarm("20260110T084000Z", "20260110T085000Z")));
78 assert!(!hit(body, &alarm("20260110T085000Z", "20260110T090000Z")));
79 let from_end = body.replace("TRIGGER:-PT15M", "TRIGGER;RELATED=END:PT5M");
80 assert!(hit(
81 &from_end,
82 &alarm("20260110T100000Z", "20260110T101000Z")
83 ));
84}
85
86#[test]
87fn freebusy_components_and_properties() {
88 let fb = "BEGIN:VFREEBUSY\r\nUID:f\r\nDTSTART:20260101T000000Z\r\nDTEND:20260102T000000Z\r\n\
89 FREEBUSY:20260101T100000Z/PT1H\r\nEND:VFREEBUSY\r\n";
90 assert!(hit(
91 fb,
92 &range("VFREEBUSY", "20260101T103000Z", "20260101T120000Z")
93 ));
94 assert!(!hit(
95 fb,
96 &range("VFREEBUSY", "20260101T120000Z", "20260101T130000Z")
97 ));
98
99 let event = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260101T100000\r\n\
100 ATTENDEE;PARTSTAT=ACCEPTED;CN=\"Bob, B\":mailto:bob@example.com\r\nEND:VEVENT\r\n";
101 let prop = |inner: &str| format!(r#"<c:comp-filter name="VEVENT">{inner}</c:comp-filter>"#);
102 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>"#;
103 assert!(hit(event, &prop(partstat)));
104 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>"#;
105 assert!(hit(event, &prop(cn)));
106 let missing = r#"<c:prop-filter name="LOCATION"><c:is-not-defined/></c:prop-filter>"#;
107 assert!(hit(event, &prop(missing)));
108 let dtstart = r#"<c:prop-filter name="DTSTART"><c:time-range start="20260101T090000Z" end="20260101T090001Z"/></c:prop-filter>"#;
109 assert!(hit(event, &prop(dtstart)));
110 let no_todo = r#"<c:comp-filter name="VTODO"><c:is-not-defined/></c:comp-filter>"#;
111 assert!(hit(event, no_todo));
112}
113
114#[test]
115fn floating_times_use_the_query_zone() {
116 let body =
117 "BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20260101T100000\r\nDURATION:PT1H\r\nEND:VEVENT\r\n";
118 let filter = range("VEVENT", "20260101T090000Z", "20260101T093000Z");
119 let Ok(Report::CalendarQuery { filter, .. }) = parse(query(&filter).as_bytes()) else {
120 panic!();
121 };
122 assert!(!matches_calendar(&cal(body), &filter, &Zone::Utc));
123 let berlin = named("Europe/Berlin").unwrap();
124 assert!(matches_calendar(&cal(body), &filter, &berlin));
125}
126
127#[test]
128fn report_bodies() {
129 let body = br#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:sync-level>1</d:sync-level>
130 <d:limit><d:nresults>5</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
131 let Ok(Report::SyncCollection { token, limit, .. }) = parse(body) else {
132 panic!();
133 };
134 assert_eq!((token.as_str(), limit), ("", Some(5)));
135
136 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>"#;
137 assert_eq!(
138 parse(body).unwrap_err(),
139 Refused::Condition(Name::new(CALDAV, "valid-filter"))
140 );
141 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>"#;
142 assert_eq!(
143 parse(body).unwrap_err(),
144 Refused::Condition(Name::new(CALDAV, "valid-timezone"))
145 );
146 let body = br#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z"/></c:free-busy-query>"#;
147 assert_eq!(parse(body).unwrap_err(), Refused::Invalid);
148 assert!(matches!(
149 parse(b"<x:nope xmlns:x=\"urn:x\"/>"),
150 Err(Refused::Condition(_))
151 ));
152}
153
154#[test]
155fn expand_keeps_dates_and_limit_keeps_used_overrides() {
156 let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
157 BEGIN:VEVENT\r\nUID:d\r\nDTSTART;VALUE=DATE:20260101\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\
158 BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260102\r\nDTSTART;VALUE=DATE:20260102\r\nSUMMARY:moved\r\nEND:VEVENT\r\n\
159 BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260104\r\nDTSTART;VALUE=DATE:20260104\r\nSUMMARY:later\r\nEND:VEVENT\r\n\
160 END:VCALENDAR\r\n";
161 let req = |inner: &str| {
162 let xml = format!(
163 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>"#
164 );
165 let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else {
166 panic!();
167 };
168 props.calendar.unwrap()
169 };
170 let out = calendar_data(
171 body,
172 &req(r#"<c:expand start="20260102T000000Z" end="20260104T000000Z"/>"#),
173 &Zone::Utc,
174 )
175 .unwrap();
176 assert!(out.contains("DTSTART;VALUE=DATE:20260102"), "{out}");
177 assert!(out.contains("RECURRENCE-ID;VALUE=DATE:20260103"), "{out}");
178 assert!(!out.contains("RRULE") && !out.contains("20260104"), "{out}");
179
180 let out = calendar_data(
181 body,
182 &req(r#"<c:limit-recurrence-set start="20260102T000000Z" end="20260103T000000Z"/>"#),
183 &Zone::Utc,
184 )
185 .unwrap();
186 assert!(
187 out.contains("RRULE") && out.contains("moved") && !out.contains("later"),
188 "{out}"
189 );
190}
191
192#[test]
193fn address_data_converts_versions() {
194 let card =
195 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:Bob\r\nTEL;TYPE=CELL:123\r\nEND:VCARD\r\n";
196 let same = AddressData::default();
197 assert_eq!(address_data(card, &same), card);
198 let v4 = AddressData {
199 props: None,
200 version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
201 };
202 assert!(address_data(card, &v4).contains("VERSION:4.0"));
203}
204
205#[test]
206fn card_filters() {
207 let card = VCard::parse(
208 "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Zoë\r\nEMAIL:z@example.com\r\nEND:VCARD\r\n",
209 )
210 .unwrap();
211 let parse_filter = |f: &str| {
212 let xml = format!(
213 r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav">{f}</card:addressbook-query>"#
214 );
215 let Ok(Report::AddressbookQuery { filter, .. }) = parse(xml.as_bytes()) else {
216 panic!("{f}");
217 };
218 filter
219 };
220 let f = parse_filter(
221 r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="starts-with">ZO</card:text-match></card:prop-filter></card:filter>"#,
222 );
223 assert!(matches_card(&card, &f));
224 let f = parse_filter(
225 r#"<card:filter><card:prop-filter name="FN"><card:text-match collation="i;octet">zoë</card:text-match></card:prop-filter></card:filter>"#,
226 );
227 assert!(!matches_card(&card, &f));
228 let f = parse_filter(
229 r#"<card:filter test="allof"><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#,
230 );
231 assert!(!matches_card(&card, &f));
232 let f = parse_filter(
233 r#"<card:filter><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#,
234 );
235 assert!(matches_card(&card, &f));
236}
237
238#[test]
239fn busy_time() {
240 let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n\
241 BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T103000Z\r\nDTEND:20260101T120000Z\r\nEND:VEVENT\r\n\
242 BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T130000Z\r\nDTEND:20260101T140000Z\r\nSTATUS:TENTATIVE\r\nEND:VEVENT\r\n\
243 BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\
244 BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n";
245 let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00");
246 let got = merge(busy(&cal(body), &range, &Zone::Utc, None));
247 let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect();
248 assert_eq!(
249 got,
250 [
251 (
252 Busy::Busy,
253 utc("2026-01-01T10:00:00"),
254 utc("2026-01-01T12:00:00")
255 ),
256 (
257 Busy::Tentative,
258 utc("2026-01-01T13:00:00"),
259 utc("2026-01-01T13:30:00")
260 ),
261 ]
262 );
263}
264
265#[test]
266fn unicode_casemap_folds_after_nfkd() {
267 use pimdav::filter::{Collation, MatchType, TextMatch};
268 let m = |text: &str, collation| TextMatch {
269 text: text.to_string(),
270 collation,
271 match_type: MatchType::Equals,
272 negate: false,
273 };
274 let unicode = Collation::UnicodeCasemap;
275 assert!(m("Müller", unicode).matches("Mu\u{0308}ller"));
276 assert!(m("file", unicode).matches("FILE"));
277 assert!(m("ix", unicode).matches("\u{2168}"));
278 assert!(m("é", Collation::AsciiCasemap).matches("é"));
279 assert!(!m("é", Collation::AsciiCasemap).matches("É"));
280}
281
282#[test]
283fn bad_filters_are_valid_filter_conditions() {
284 let bad_range = query(
285 r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#,
286 );
287 assert!(matches!(
288 parse(bad_range.as_bytes()),
289 Err(Refused::Condition(n)) if n == Name::new(CALDAV, "valid-filter")
290 ));
291 let bad_test = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
292 <card:filter test="sometimes"/></card:addressbook-query>"#;
293 assert!(matches!(
294 parse(bad_test.as_bytes()),
295 Err(Refused::Condition(n)) if n == Name::new(pimdav::xml::CARDDAV, "valid-filter")
296 ));
297}
298
299#[test]
300fn busy_time_follows_own_answer() {
301 const ME: &str = "mailto:room@rooms.filebrowser.invalid";
302 let me = |a: &str| a.eq_ignore_ascii_case(ME);
303 let body = format!(
304 "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nATTENDEE;PARTSTAT=DECLINED:{ME}\r\nEND:VEVENT\r\n\
305 BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nATTENDEE:{ME}\r\nEND:VEVENT\r\n\
306 BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T140000Z\r\nDTEND:20260101T150000Z\r\nATTENDEE;PARTSTAT=ACCEPTED:{ME}\r\nEND:VEVENT\r\n"
307 );
308 let range = utc("2026-01-01T00:00:00")..utc("2026-01-02T00:00:00");
309 let got: Vec<_> = merge(busy(&cal(&body), &range, &Zone::Utc, Some(&me)))
310 .iter()
311 .map(|p| (p.kind, p.start))
312 .collect();
313 assert_eq!(
314 got,
315 [
316 (Busy::Busy, utc("2026-01-01T14:00:00")),
317 (Busy::Tentative, utc("2026-01-01T12:00:00")),
318 ]
319 );
320 // Without a calendar user, the answers do not count (RFC 4791).
321 assert_eq!(merge(busy(&cal(&body), &range, &Zone::Utc, None)).len(), 3);
322}
323
324#[test]
325fn outbox_free_busy_request() {
326 use pimdav::freebusy::{reply, request, schedule_response};
327 let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\
328 BEGIN:VFREEBUSY\r\nUID:fb-1\r\nDTSTAMP:20260101T000000Z\r\n\
329 DTSTART:20260105T000000Z\r\nDTEND:20260106T000000Z\r\n\
330 ORGANIZER:mailto:alice@filebrowser.invalid\r\n\
331 ATTENDEE:mailto:bob@filebrowser.invalid\r\n\
332 ATTENDEE:mailto:BOB@filebrowser.invalid\r\n\
333 ATTENDEE:mailto:dave@example.com\r\n\
334 END:VFREEBUSY\r\nEND:VCALENDAR\r\n";
335 let req = request(body.as_bytes()).unwrap();
336 assert_eq!(
337 req.range,
338 utc("2026-01-05T00:00:00")..utc("2026-01-06T00:00:00")
339 );
340 assert_eq!(req.organizer, "mailto:alice@filebrowser.invalid");
341 assert_eq!(
342 req.attendees,
343 ["mailto:bob@filebrowser.invalid", "mailto:dave@example.com"]
344 );
345
346 let busy = [pimdav::freebusy::Period {
347 kind: Busy::Busy,
348 start: utc("2026-01-05T10:00:00"),
349 end: utc("2026-01-05T11:00:00"),
350 }];
351 let text = reply(&busy, &req, &req.attendees[0], utc("2026-01-01T00:00:00"));
352 let got = ICalendar::parse(&text).unwrap().to_string();
353 for line in [
354 "METHOD:REPLY",
355 "UID:fb-1",
356 "ATTENDEE:mailto:bob@filebrowser.invalid",
357 "FREEBUSY;FBTYPE=BUSY:20260105T100000Z/20260105T110000Z",
358 ] {
359 assert!(got.contains(line), "{line} in {got}");
360 }
361
362 let xml = schedule_response(&[(
363 req.attendees[1].clone(),
364 "5.2;Invalid calendar service",
365 None,
366 )]);
367 assert!(xml.contains("<c:schedule-response"), "{xml}");
368 assert!(xml.contains("<c:request-status>5.2;Invalid calendar service</c:request-status>"));
369
370 // Not a request, or a request without the parts it needs.
371 for bad in [
372 body.replace("METHOD:REQUEST", "METHOD:PUBLISH"),
373 body.replace("DTEND:20260106T000000Z\r\n", ""),
374 body.replace("ORGANIZER:mailto:alice@filebrowser.invalid\r\n", ""),
375 ] {
376 let err = request(bad.as_bytes()).unwrap_err();
377 assert!(err.is(CALDAV, "valid-scheduling-message"), "{bad}");
378 }
379}
380