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#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:sync-level>1</d:sync-level>
137 <d:limit><d:nresults>0</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#;
138 let Ok(Report::SyncCollection { limit, .. }) = parse(body) else {
139 panic!();
140 };
141 assert_eq!(limit, None);
142
143 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>"#;
144 assert_eq!(
145 parse(body).unwrap_err(),
146 Refused::Condition(Name::new(CALDAV, "valid-filter"))
147 );
148 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>"#;
149 assert_eq!(
150 parse(body).unwrap_err(),
151 Refused::Condition(Name::new(CALDAV, "valid-timezone"))
152 );
153 let body = br#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z"/></c:free-busy-query>"#;
154 assert_eq!(parse(body).unwrap_err(), Refused::Invalid);
155 assert!(matches!(
156 parse(b"<x:nope xmlns:x=\"urn:x\"/>"),
157 Err(Refused::Condition(_))
158 ));
159}
160
161#[test]
162fn expand_keeps_dates_and_limit_keeps_used_overrides() {
163 let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
164 BEGIN:VEVENT\r\nUID:d\r\nDTSTART;VALUE=DATE:20260101\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\
165 BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260102\r\nDTSTART;VALUE=DATE:20260102\r\nSUMMARY:moved\r\nEND:VEVENT\r\n\
166 BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260104\r\nDTSTART;VALUE=DATE:20260104\r\nSUMMARY:later\r\nEND:VEVENT\r\n\
167 END:VCALENDAR\r\n";
168 let req = |inner: &str| {
169 let xml = format!(
170 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>"#
171 );
172 let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else {
173 panic!();
174 };
175 props.calendar.unwrap()
176 };
177 let out = calendar_data(
178 body,
179 &req(r#"<c:expand start="20260102T000000Z" end="20260104T000000Z"/>"#),
180 &Zone::Utc,
181 )
182 .unwrap()
183 .0;
184 assert!(out.contains("DTSTART;VALUE=DATE:20260102"), "{out}");
185 assert!(out.contains("RECURRENCE-ID;VALUE=DATE:20260103"), "{out}");
186 assert!(!out.contains("RRULE") && !out.contains("20260104"), "{out}");
187
188 let out = calendar_data(
189 body,
190 &req(r#"<c:limit-recurrence-set start="20260102T000000Z" end="20260103T000000Z"/>"#),
191 &Zone::Utc,
192 )
193 .unwrap()
194 .0;
195 assert!(
196 out.contains("RRULE") && out.contains("moved") && !out.contains("later"),
197 "{out}"
198 );
199}
200
201#[test]
202fn address_data_converts_versions() {
203 let card =
204 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:Bob\r\nTEL;TYPE=CELL:123\r\nEND:VCARD\r\n";
205 let same = AddressData::default();
206 assert_eq!(address_data(card, &same), card);
207 let v4 = AddressData {
208 props: None,
209 version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
210 };
211 assert!(address_data(card, &v4).contains("VERSION:4.0"));
212}
213
214#[test]
215fn groups_and_companies_in_apple_forms() {
216 use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
217 let to = |v| AddressData {
218 props: None,
219 version: Some(v),
220 };
221 let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\n\
222 MEMBER:urn:uuid:m1\r\nEMAIL;PREF=1;TYPE=work:t@example.com\r\nEND:VCARD\r\n";
223 let v3 = address_data(group, &to(V3_0));
224 assert!(v3.contains("VERSION:3.0"), "{v3}");
225 assert!(v3.contains("X-ADDRESSBOOKSERVER-KIND:group\r\n"), "{v3}");
226 assert!(
227 v3.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:m1\r\n"),
228 "{v3}"
229 );
230 assert!(
231 !v3.contains("\r\nKIND") && !v3.contains("\r\nMEMBER"),
232 "{v3}"
233 );
234 assert!(v3.contains("TYPE=pref") && !v3.contains("PREF=1"), "{v3}");
235
236 // Back to 4.0, as a client asking for it gets an Apple-made card.
237 let back = address_data(&v3, &to(V4_0));
238 assert!(
239 back.to_ascii_lowercase().contains("\r\nkind:group\r\n"),
240 "{back}"
241 );
242 assert!(back.contains("MEMBER:urn:uuid:m1\r\n"), "{back}");
243 assert!(
244 back.contains("PREF=1") && !back.contains("X-ADDRESSBOOKSERVER"),
245 "{back}"
246 );
247
248 let ranked = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:r\r\nFN:R\r\n\
249 EMAIL;PREF=3:c@example.com\r\nEMAIL;PREF=2:b@example.com\r\nEND:VCARD\r\n";
250 let v3 = address_data(ranked, &to(V3_0));
251 assert!(v3.contains("EMAIL;TYPE=pref:b@example.com"), "{v3}");
252 assert!(v3.contains("EMAIL:c@example.com"), "{v3}");
253
254 let org = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:ACME\r\nKIND:org\r\nEND:VCARD\r\n";
255 assert!(address_data(org, &to(V3_0)).contains("X-ABSHOWAS:COMPANY\r\n"));
256 let apple = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:o\r\nFN:ACME\r\nX-ABSHOWAS:COMPANY\r\n\
257 TEL;TYPE=\"CELL,pref\":1\r\nEND:VCARD\r\n";
258 let v4 = address_data(apple, &to(V4_0));
259 assert!(v4.to_ascii_lowercase().contains("\r\nkind:org\r\n"), "{v4}");
260 assert!(v4.contains("TYPE=CELL") && v4.contains("PREF=1"), "{v4}");
261}
262
263#[test]
264fn vcard_three_is_the_default() {
265 use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
266 use pimdav::render::{accepted_version, address_request};
267 use pimdav::xml::{CARDDAV, el};
268 assert_eq!(
269 address_request(&el(CARDDAV, "address-data"))
270 .unwrap()
271 .version,
272 Some(V3_0)
273 );
274 assert_eq!(accepted_version(None), V3_0);
275 assert_eq!(accepted_version(Some("text/vcard")), V3_0);
276 assert_eq!(accepted_version(Some("text/vcard; version=4.0")), V4_0);
277}
278
279#[test]
280fn card_filters() {
281 let card = VCard::parse(
282 "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Zoë\r\nEMAIL:z@example.com\r\nEND:VCARD\r\n",
283 )
284 .unwrap();
285 let parse_filter = |f: &str| {
286 let xml = format!(
287 r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav">{f}</card:addressbook-query>"#
288 );
289 let Ok(Report::AddressbookQuery { filter, .. }) = parse(xml.as_bytes()) else {
290 panic!("{f}");
291 };
292 filter
293 };
294 let f = parse_filter(
295 r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="starts-with">ZO</card:text-match></card:prop-filter></card:filter>"#,
296 );
297 assert!(matches_card(&card, &f));
298 let f = parse_filter(
299 r#"<card:filter><card:prop-filter name="FN"><card:text-match collation="i;octet">zoë</card:text-match></card:prop-filter></card:filter>"#,
300 );
301 assert!(!matches_card(&card, &f));
302 let f = parse_filter(
303 r#"<card:filter test="allof"><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#,
304 );
305 assert!(!matches_card(&card, &f));
306 let f = parse_filter(
307 r#"<card:filter><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#,
308 );
309 assert!(matches_card(&card, &f));
310}
311
312#[test]
313fn busy_time() {
314 let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n\
315 BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T103000Z\r\nDTEND:20260101T120000Z\r\nEND:VEVENT\r\n\
316 BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T130000Z\r\nDTEND:20260101T140000Z\r\nSTATUS:TENTATIVE\r\nEND:VEVENT\r\n\
317 BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\
318 BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n";
319 let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00");
320 let got = merge(busy(&cal(body), &range, &Zone::Utc, None));
321 let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect();
322 assert_eq!(
323 got,
324 [
325 (
326 Busy::Busy,
327 utc("2026-01-01T10:00:00"),
328 utc("2026-01-01T12:00:00")
329 ),
330 (
331 Busy::Tentative,
332 utc("2026-01-01T13:00:00"),
333 utc("2026-01-01T13:30:00")
334 ),
335 ]
336 );
337}
338
339#[test]
340fn unicode_casemap_folds_after_nfkd() {
341 use pimdav::filter::{Collation, MatchType, TextMatch};
342 let m = |text: &str, collation| TextMatch {
343 text: text.to_string(),
344 collation,
345 match_type: MatchType::Equals,
346 negate: false,
347 };
348 let unicode = Collation::UnicodeCasemap;
349 assert!(m("Müller", unicode).matches("Mu\u{0308}ller"));
350 assert!(m("file", unicode).matches("FILE"));
351 assert!(m("ix", unicode).matches("\u{2168}"));
352 assert!(m("é", Collation::AsciiCasemap).matches("é"));
353 assert!(!m("é", Collation::AsciiCasemap).matches("É"));
354}
355
356#[test]
357fn bad_filters_are_valid_filter_conditions() {
358 let bad_range = query(
359 r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#,
360 );
361 assert!(matches!(
362 parse(bad_range.as_bytes()),
363 Err(Refused::Condition(n)) if n == Name::new(CALDAV, "valid-filter")
364 ));
365 let bad_test = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
366 <card:filter test="sometimes"/></card:addressbook-query>"#;
367 assert!(matches!(
368 parse(bad_test.as_bytes()),
369 Err(Refused::Condition(n)) if n == Name::new(pimdav::xml::CARDDAV, "valid-filter")
370 ));
371}
372
373#[test]
374fn busy_time_follows_own_answer() {
375 const ME: &str = "mailto:room@rooms.dovenest.invalid";
376 let me = |a: &str| a.eq_ignore_ascii_case(ME);
377 let body = format!(
378 "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nATTENDEE;PARTSTAT=DECLINED:{ME}\r\nEND:VEVENT\r\n\
379 BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nATTENDEE:{ME}\r\nEND:VEVENT\r\n\
380 BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T140000Z\r\nDTEND:20260101T150000Z\r\nATTENDEE;PARTSTAT=ACCEPTED:{ME}\r\nEND:VEVENT\r\n"
381 );
382 let range = utc("2026-01-01T00:00:00")..utc("2026-01-02T00:00:00");
383 let got: Vec<_> = merge(busy(&cal(&body), &range, &Zone::Utc, Some(&me)))
384 .iter()
385 .map(|p| (p.kind, p.start))
386 .collect();
387 assert_eq!(
388 got,
389 [
390 (Busy::Busy, utc("2026-01-01T14:00:00")),
391 (Busy::Tentative, utc("2026-01-01T12:00:00")),
392 ]
393 );
394 // Without a calendar user, the answers do not count (RFC 4791).
395 assert_eq!(merge(busy(&cal(&body), &range, &Zone::Utc, None)).len(), 3);
396}
397
398#[test]
399fn outbox_free_busy_request() {
400 use pimdav::freebusy::{reply, request, schedule_response};
401 let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\
402 BEGIN:VFREEBUSY\r\nUID:fb-1\r\nDTSTAMP:20260101T000000Z\r\n\
403 DTSTART:20260105T000000Z\r\nDTEND:20260106T000000Z\r\n\
404 ORGANIZER:mailto:alice@dovenest.invalid\r\n\
405 ATTENDEE:mailto:bob@dovenest.invalid\r\n\
406 ATTENDEE:mailto:BOB@dovenest.invalid\r\n\
407 ATTENDEE:mailto:dave@example.com\r\n\
408 END:VFREEBUSY\r\nEND:VCALENDAR\r\n";
409 let req = request(body.as_bytes()).unwrap();
410 assert_eq!(
411 req.range,
412 utc("2026-01-05T00:00:00")..utc("2026-01-06T00:00:00")
413 );
414 assert_eq!(req.organizer, "mailto:alice@dovenest.invalid");
415 assert_eq!(
416 req.attendees,
417 ["mailto:bob@dovenest.invalid", "mailto:dave@example.com"]
418 );
419
420 let busy = [pimdav::freebusy::Period {
421 kind: Busy::Busy,
422 start: utc("2026-01-05T10:00:00"),
423 end: utc("2026-01-05T11:00:00"),
424 }];
425 let text = reply(&busy, &req, &req.attendees[0], utc("2026-01-01T00:00:00"));
426 let got = ICalendar::parse(&text).unwrap().to_string();
427 for line in [
428 "METHOD:REPLY",
429 "UID:fb-1",
430 "ATTENDEE:mailto:bob@dovenest.invalid",
431 "FREEBUSY;FBTYPE=BUSY:20260105T100000Z/20260105T110000Z",
432 ] {
433 assert!(got.contains(line), "{line} in {got}");
434 }
435
436 let xml = schedule_response(&[(
437 req.attendees[1].clone(),
438 "5.2;Invalid calendar service",
439 None,
440 )]);
441 assert!(xml.contains("<c:schedule-response"), "{xml}");
442 assert!(xml.contains("<c:request-status>5.2;Invalid calendar service</c:request-status>"));
443
444 // Not a request, or a request without the parts it needs.
445 for bad in [
446 body.replace("METHOD:REQUEST", "METHOD:PUBLISH"),
447 body.replace("DTEND:20260106T000000Z\r\n", ""),
448 body.replace("ORGANIZER:mailto:alice@dovenest.invalid\r\n", ""),
449 ] {
450 let err = request(bad.as_bytes()).unwrap_err();
451 assert!(err.is(CALDAV, "valid-scheduling-message"), "{bad}");
452 }
453}
454
455#[test]
456fn text_match_ignores_pretty_printing() {
457 let body =
458 "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20240101T100000Z\r\nSUMMARY:John Smith\r\nEND:VEVENT\r\n";
459 let filter = r#"<c:comp-filter name="VEVENT"><c:prop-filter name="SUMMARY">
460 <c:text-match match-type="equals">
461 John Smith
462 </c:text-match></c:prop-filter></c:comp-filter>"#;
463 assert!(hit(body, filter));
464}
465
466#[test]
467fn many_overrides_expand_once_per_query() {
468 let mut body = String::from(
469 "BEGIN:VEVENT\r\nUID:s\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\n",
470 );
471 for d in 0..2000 {
472 let day = chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap() + chrono::Days::new(d);
473 let t = day.format("%Y%m%dT100000Z");
474 body.push_str(&format!(
475 "BEGIN:VEVENT\r\nUID:s\r\nRECURRENCE-ID:{t}\r\nDTSTART:{t}\r\nSUMMARY:{d}\r\nBEGIN:VALARM\r\nTRIGGER:-PT{}M\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\n",
476 d % 50 + 1
477 ));
478 }
479 let started = std::time::Instant::now();
480 assert!(!hit(
481 &body,
482 &range("VEVENT", "19990101T000000Z", "19990102T000000Z")
483 ));
484 let alarm = r#"<c:comp-filter name="VEVENT"><c:comp-filter name="VALARM"><c:time-range start="19990101T000000Z" end="19990102T000000Z"/></c:comp-filter></c:comp-filter>"#;
485 assert!(!hit(&body, alarm));
486 assert!(
487 started.elapsed() < std::time::Duration::from_secs(2),
488 "{:?}",
489 started.elapsed()
490 );
491}
492
493#[test]
494fn an_open_range_on_an_endless_rule_needs_no_expansion() {
495 let body = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20000101T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=MINUTELY\r\n\
496 BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n";
497 let open = r#"<c:time-range start="20300101T000000Z"/>"#;
498 let event = format!(r#"<c:comp-filter name="VEVENT">{open}</c:comp-filter>"#);
499 let alarm = format!(
500 r#"<c:comp-filter name="VEVENT"><c:comp-filter name="VALARM">{open}</c:comp-filter></c:comp-filter>"#
501 );
502 let t = std::time::Instant::now();
503 assert!(hit(body, &event));
504 assert!(hit(body, &alarm));
505 assert!(
506 t.elapsed() < std::time::Duration::from_millis(500),
507 "{:?}",
508 t.elapsed()
509 );
510}
511
512#[test]
513fn a_photo_data_uri_survives_the_conversion_to_4_0() {
514 let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p\r\nFN:Pic\r\n\
515 PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRg==\r\nEND:VCARD\r\n";
516 let v4 = AddressData {
517 props: None,
518 version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
519 };
520 let out = address_data(card, &v4).replace("\r\n ", "");
521 assert!(out.contains("data:image/jpeg;base64,/9j/"), "{out}");
522 assert!(!out.contains("\\,"), "{out}");
523}
524
525#[test]
526fn a_photo_without_type_gets_its_media_type_in_4_0() {
527 let v4 = AddressData {
528 props: None,
529 version: Some(pimdav::calcard::vcard::VCardVersion::V4_0),
530 };
531 for (data, mime) in [
532 (
533 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg==",
534 "image/png",
535 ),
536 ("/9j/4AAQSkZJRgABAQ==", "image/jpeg"),
537 (
538 "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==",
539 "image/gif",
540 ),
541 (
542 "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==",
543 "image/webp",
544 ),
545 ] {
546 let card = format!(
547 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p\r\nFN:Pic\r\nPHOTO;ENCODING=b:{data}\r\nEND:VCARD\r\n"
548 );
549 let out = address_data(&card, &v4).replace("\r\n ", "");
550 assert!(out.contains(&format!("data:{mime};base64,{data}")), "{out}");
551 }
552 // A stored 4.0 card in upper case is sniffed too.
553 let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:p\r\nFN:Pic\r\nPHOTO:DATA:;BASE64,/9j/4AAQSkZJRgABAQ==\r\nEND:VCARD\r\n";
554 let photo = AddressData {
555 props: Some(vec![pimdav::render::PropSelect {
556 name: "PHOTO".into(),
557 novalue: false,
558 }]),
559 ..v4
560 };
561 let out = address_data(card, &photo).replace("\r\n ", "");
562 assert!(out.contains("data:image/jpeg;base64,/9j/"), "{out}");
563}
564
565#[test]
566fn expand_keeps_a_task_without_start() {
567 let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nDUE:20260105T100000Z\r\n\
568 SUMMARY:Pay\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
569 let req = pimdav::render::CalendarData {
570 expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")),
571 ..Default::default()
572 };
573 let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
574 assert!(
575 out.contains("BEGIN:VTODO") && out.contains("SUMMARY:Pay"),
576 "{out}"
577 );
578}
579
580#[test]
581fn expand_puts_a_kept_task_in_utc() {
582 let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
583 BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\
584 DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\
585 END:STANDARD\r\nEND:VTIMEZONE\r\n\
586 BEGIN:VTODO\r\nUID:t\r\nDUE;TZID=Europe/Berlin:20260105T100000\r\n\
587 SUMMARY:Pay\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
588 let req = pimdav::render::CalendarData {
589 expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")),
590 ..Default::default()
591 };
592 let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
593 assert!(out.contains("DUE:20260105T090000Z"), "{out}");
594 assert!(!out.contains("TZID"), "{out}");
595}
596
597#[test]
598fn limit_recurrence_keeps_an_override_moved_out_of_the_range() {
599 let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
600 BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\
601 BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID:20260102T100000Z\r\nDTSTART:20260301T100000Z\r\nEND:VEVENT\r\n\
602 END:VCALENDAR\r\n";
603 let req = pimdav::render::CalendarData {
604 limit_recurrence: Some(utc("2026-01-01T00:00:00")..utc("2026-01-05T00:00:00")),
605 ..Default::default()
606 };
607 let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
608 assert!(out.contains("RECURRENCE-ID:20260102T100000Z"), "{out}");
609}
610
611#[test]
612fn limit_recurrence_keeps_an_override_whose_original_overlaps_the_range() {
613 let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
614 BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T093000Z\r\nDTEND:20260101T103000Z\r\n\
615 RRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\
616 BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID:20260102T093000Z\r\n\
617 DTSTART:20260301T093000Z\r\nDTEND:20260301T103000Z\r\nEND:VEVENT\r\n\
618 END:VCALENDAR\r\n";
619 let req = pimdav::render::CalendarData {
620 limit_recurrence: Some(utc("2026-01-02T10:00:00")..utc("2026-01-02T11:00:00")),
621 ..Default::default()
622 };
623 let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
624 assert!(out.contains("RECURRENCE-ID:20260102T093000Z"), "{out}");
625}
626
627#[test]
628fn expand_puts_every_zoned_time_of_an_instance_in_utc() {
629 let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
630 BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\
631 DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\
632 END:STANDARD\r\nEND:VTIMEZONE\r\n\
633 BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260105T100000\r\n\
634 CREATED;TZID=Europe/Berlin:20251201T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
635 let req = pimdav::render::CalendarData {
636 expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")),
637 ..Default::default()
638 };
639 let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0;
640 assert!(out.contains("CREATED:20251201T090000Z"), "{out}");
641 assert!(!out.contains("TZID"), "{out}");
642}
643
644#[test]
645fn vcard_three_gets_plain_numbers_and_apple_birthdays() {
646 let v4 = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:A\r\n\
647 TEL;VALUE=uri;TYPE=cell:tel:+49-1\r\nBDAY:--0315\r\nEND:VCARD\r\n";
648 let to = |v| AddressData {
649 props: None,
650 version: Some(v),
651 };
652 use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
653 let v3 = address_data(v4, &to(V3_0));
654 assert!(v3.contains("TEL;TYPE=CELL:+49-1\r\n"), "{v3}");
655 assert!(
656 v3.contains("BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\r\n"),
657 "{v3}"
658 );
659 let back = address_data(&v3, &to(V4_0));
660 assert!(back.contains("BDAY:--0315\r\n"), "{back}");
661}
662
663#[test]
664fn an_instance_moved_by_a_range_takes_the_overrides_component() {
665 let cal = cal(concat!(
666 "BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20240101T000000Z\r\nDTSTART:20240101T100000Z\r\n",
667 "DURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Old\r\nEND:VEVENT\r\n",
668 "BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20240101T000000Z\r\n",
669 "RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\n",
670 "DURATION:PT1H\r\nSUMMARY:New\r\nEND:VEVENT\r\n",
671 ));
672 let rid = Some(utc("2024-01-04T10:00:00"));
673 let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap();
674 // The instance belongs to the series, not to an override of its own.
675 assert!(
676 !cal.components[index]
677 .has_property(&pimdav::calcard::icalendar::ICalendarProperty::RecurrenceId)
678 );
679 let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap();
680 let summary = cal.components[i.component]
681 .property(&pimdav::calcard::icalendar::ICalendarProperty::Summary)
682 .and_then(|e| e.values.first()?.as_text().map(str::to_string));
683 assert_eq!(summary.as_deref(), Some("New"));
684}
685
686#[test]
687fn expand_refuses_an_answer_too_big_to_build() {
688 let attendees: String = (0..2000)
689 .map(|i| format!("ATTENDEE:mailto:user{i}@example.com\r\n"))
690 .collect();
691 let raw = format!(
692 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:big\r\n\
693 DTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n"
694 );
695 let req = |days: i64| pimdav::render::CalendarData {
696 expand: Some(
697 utc("2026-01-01T00:00:00")..utc("2026-01-01T00:00:00") + chrono::TimeDelta::days(days),
698 ),
699 ..Default::default()
700 };
701 assert!(calendar_data(&raw, &req(10), &Zone::Utc).is_ok());
702 assert!(calendar_data(&raw, &req(300), &Zone::Utc).is_err());
703 // Without the attendees the same expansion is small.
704 let body = r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
705 <d:prop><c:calendar-data><c:comp name="VCALENDAR">
706 <c:comp name="VEVENT"><c:prop name="UID"/><c:prop name="DTSTART"/></c:comp>
707 </c:comp></c:calendar-data></d:prop>
708 <d:href>/x.ics</d:href></c:calendar-multiget>"#;
709 let Ok(Report::CalendarMultiget { props, .. }) = parse(body.as_bytes()) else {
710 panic!("bad multiget");
711 };
712 let picked = pimdav::render::CalendarData {
713 expand: req(300).expand,
714 ..props.calendar.unwrap()
715 };
716 assert!(calendar_data(&raw, &picked, &Zone::Utc).is_ok());
717}
718
719#[test]
720fn a_task_alarm_without_start_fires_from_due() {
721 let task = "BEGIN:VTODO\r\nUID:t\r\nDUE:20260110T120000Z\r\n\
722 BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\nEND:VTODO\r\n";
723 let alarm = |start, end| {
724 format!(
725 r#"<c:comp-filter name="VTODO">{}</c:comp-filter>"#,
726 range("VALARM", start, end)
727 )
728 };
729 assert!(hit(task, &alarm("20260110T114000Z", "20260110T115000Z")));
730 assert!(!hit(task, &alarm("20260110T115000Z", "20260110T120000Z")));
731}
732
733#[test]
734fn an_empty_comp_selects_the_whole_component() {
735 let body = r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
736 <d:prop><c:calendar-data><c:comp name="VCALENDAR"><c:allprop/>
737 <c:comp name="VEVENT"><c:prop name="UID"/><c:prop name="DTSTART"/></c:comp>
738 <c:comp name="VTIMEZONE"/>
739 </c:comp></c:calendar-data></d:prop>
740 <d:href>/x.ics</d:href></c:calendar-multiget>"#;
741 let Ok(Report::CalendarMultiget { props, .. }) = parse(body.as_bytes()) else {
742 panic!("bad multiget");
743 };
744 let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
745 BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\
746 DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\
747 END:STANDARD\r\nEND:VTIMEZONE\r\n\
748 BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260105T100000\r\n\
749 SUMMARY:Hidden\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
750 let out = calendar_data(raw, &props.calendar.unwrap(), &Zone::Utc)
751 .unwrap()
752 .0;
753 assert!(out.contains("TZID:Europe/Berlin"), "{out}");
754 assert!(out.contains("TZOFFSETTO:+0100"), "{out}");
755 assert!(!out.contains("SUMMARY"), "{out}");
756}
757
758#[test]
759fn uri_values_keep_their_commas_and_semicolons() {
760 let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n\
761 BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\n\
762 LOCATION:A\\, B\r\n\
763 X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-TITLE=Home:geo:52.5,13.4\r\n\
764 URL:http://x/a,b;c\r\n\
765 ATTACH:http://x/f,1;2\r\n\
766 CONFERENCE;VALUE=URI:https://meet/x,y;z\r\n\
767 X-GOOGLE-CONFERENCE:https://meet.google.com/a,b\r\n\
768 END:VEVENT\r\nEND:VCALENDAR\r\n";
769 let req = |inner: &str| {
770 let xml = format!(
771 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>"#
772 );
773 let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else {
774 panic!();
775 };
776 props.calendar.unwrap()
777 };
778 for inner in [
779 r#"<c:comp name="VCALENDAR"><c:allprop/><c:comp name="VEVENT"><c:allprop/></c:comp></c:comp>"#,
780 r#"<c:expand start="20260101T000000Z" end="20260102T000000Z"/>"#,
781 r#"<c:limit-recurrence-set start="20260101T000000Z" end="20260102T000000Z"/>"#,
782 ] {
783 let out = calendar_data(body, &req(inner), &Zone::Utc).unwrap().0;
784 let out = out.replace("\r\n ", "");
785 assert!(out.contains(":geo:52.5,13.4"), "{out}");
786 assert!(out.contains("URL:http://x/a,b;c"), "{out}");
787 assert!(out.contains(":http://x/f,1;2"), "{out}");
788 assert!(out.contains(":https://meet/x,y;z"), "{out}");
789 assert!(out.contains(":https://meet.google.com/a,b"), "{out}");
790 assert!(out.contains("LOCATION:A\\, B"), "{out}");
791 }
792}
793
794#[test]
795fn card_uris_and_geo_survive_conversion() {
796 use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
797 let to = |v| AddressData {
798 props: None,
799 version: Some(v),
800 };
801 let v3 = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:g\r\nFN:A\r\n\
802 X-SOCIALPROFILE;type=x:http://t/a,b;c\r\nGEO:52.5;13.4\r\nEND:VCARD\r\n";
803 let v4 = address_data(v3, &to(V4_0));
804 let flat = v4.replace("\r\n ", "");
805 assert!(flat.contains(":http://t/a,b;c"), "{v4}");
806 assert!(flat.contains("GEO:geo:52.5,13.4"), "{v4}");
807 let back = address_data(&v4, &to(V3_0));
808 let flat = back.replace("\r\n ", "");
809 assert!(flat.contains(":http://t/a,b;c"), "{back}");
810 assert!(flat.contains("GEO:52.5;13.4"), "{back}");
811 // vCard 3 GEO has no altitude.
812 let high = v4.replace("geo:52.5,13.4", "geo:52.5,13.4,34");
813 let flat = address_data(&high, &to(V3_0)).replace("\r\n ", "");
814 assert!(flat.contains("GEO:52.5;13.4\r\n"), "{flat}");
815}
816
817#[test]
818fn binary_values_that_are_text_keep_their_encoding() {
819 use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
820 let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n\
821 BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\n\
822 ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:SGVsbG8gd29ybGQKbGluZTI=\r\n\
823 END:VEVENT\r\nEND:VCALENDAR\r\n";
824 let out = pimdav::render::write(&pimdav::render::parse(body).unwrap()).replace("\r\n ", "");
825 assert!(
826 out.contains(
827 "ATTACH;ENCODING=BASE64;FMTTYPE=text/plain;VALUE=BINARY:SGVsbG8gd29ybGQKbGluZTI=\r\n"
828 ),
829 "{out}"
830 );
831 // "-----BEGIN PGP-----\nabc,d;e\n"
832 let key = "LS0tLS1CRUdJTiBQR1AtLS0tLQphYmMsZDtlCg==";
833 let v3 = format!(
834 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:g\r\nFN:A\r\nKEY;TYPE=PGP;ENCODING=b:{key}\r\n\
835 PHOTO;VALUE=uri:http://x/p.jpg\r\nEND:VCARD\r\n"
836 );
837 let to = |v, props| AddressData {
838 props,
839 version: Some(v),
840 };
841 let v4 = address_data(&v3, &to(V4_0, None)).replace("\r\n ", "");
842 assert!(
843 v4.contains(&format!(":data:application/pgp-keys;base64,{key}\r\n")),
844 "{v4}"
845 );
846 assert!(v4.contains(":http://x/p.jpg\r\n"), "{v4}");
847 let keys = Some(vec![pimdav::render::PropSelect {
848 name: "KEY".into(),
849 novalue: false,
850 }]);
851 let only = address_data(&v3, &to(V3_0, keys)).replace("\r\n ", "");
852 assert!(only.contains(&format!(";ENCODING=b:{key}\r\n")), "{only}");
853}
854
855#[test]
856fn only_values_sent_in_base64_are_written_in_base64() {
857 use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0};
858 let event = |attach: &str| {
859 let body = format!(
860 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nBEGIN:VEVENT\r\nUID:u\r\n\
861 DTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\n{attach}\r\n\
862 END:VEVENT\r\nEND:VCALENDAR\r\n"
863 );
864 pimdav::render::write(&pimdav::render::parse(&body).unwrap()).replace("\r\n ", "")
865 };
866 let raw = event("ATTACH;VALUE=BINARY;FMTTYPE=image/png:iVBORw0KGgo=");
867 assert!(
868 raw.contains(":iVBORw0KGgo=\r\n") && !raw.contains("ENCODING"),
869 "{raw}"
870 );
871 // "a" CR LF "b"
872 let crlf = event("ATTACH;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:YQ0KYg==");
873 assert!(crlf.contains(":YQ0KYg==\r\n"), "{crlf}");
874
875 let card = |version: &str, line: &str, to: pimdav::calcard::vcard::VCardVersion| {
876 let text =
877 format!("BEGIN:VCARD\r\nVERSION:{version}\r\nUID:g\r\nFN:A\r\n{line}\r\nEND:VCARD\r\n");
878 let req = AddressData {
879 props: Some(
880 ["PHOTO", "KEY"]
881 .map(|name| pimdav::render::PropSelect {
882 name: name.into(),
883 novalue: false,
884 })
885 .to_vec(),
886 ),
887 version: Some(to),
888 };
889 address_data(&text, &req).replace("\r\n ", "")
890 };
891 for (version, line, to, want) in [
892 ("3.0", "PHOTO;TYPE=JPEG:/9j/4AAQ", V3_0, ":/9j/4AAQ\r\n"),
893 (
894 "4.0",
895 "PHOTO:www.example.com/photo.jpg",
896 V4_0,
897 ":www.example.com/photo.jpg\r\n",
898 ),
899 (
900 "4.0",
901 "PHOTO:www.example.com/photo.jpg",
902 V3_0,
903 ":www.example.com/photo.jpg\r\n",
904 ),
905 (
906 "3.0",
907 "KEY;TYPE=PGP:-----BEGIN PGP-----",
908 V3_0,
909 ":-----BEGIN PGP-----\r\n",
910 ),
911 ] {
912 let out = card(version, line, to);
913 assert!(
914 out.contains(want) && !out.contains("ENCODING") && !out.contains("data:"),
915 "{out}"
916 );
917 }
918 // "k" CR LF "l", and bytes that look like a URI
919 for (line, want) in [
920 (
921 "KEY;TYPE=PGP;ENCODING=b:aw0KbA==",
922 ";ENCODING=b:aw0KbA==\r\n",
923 ),
924 (
925 "PHOTO;ENCODING=b:aHR0cDovL3gveQ==",
926 ";ENCODING=b:aHR0cDovL3gveQ==\r\n",
927 ),
928 ("PHOTO;VALUE=BINARY;ENCODING=b:SGVsbG8=", ":SGVsbG8=\r\n"),
929 ] {
930 let out = card("3.0", line, V3_0);
931 assert!(
932 out.contains(want) && out.contains("ENCODING=b") && !out.contains("BASE64"),
933 "{out}"
934 );
935 }
936 let v4 = card("3.0", "KEY;TYPE=PGP;ENCODING=b:aw0KbA==", V4_0);
937 assert!(
938 v4.contains(":data:application/pgp-keys;base64,aw0KbA==\r\n"),
939 "{v4}"
940 );
941 // Only media and keys are binary: a base64 NOTE stays text.
942 let note = address_data(
943 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:g\r\nFN:A\r\nNOTE;ENCODING=b:SGVsbG8=\r\nEND:VCARD\r\n",
944 &AddressData {
945 props: None,
946 version: Some(V4_0),
947 },
948 );
949 assert!(note.contains("NOTE:Hello\r\n"), "{note}");
950 // A plain value in another component stays plain, though it equals the
951 // decoded bytes of the first.
952 let two = pimdav::render::write(
953 &pimdav::render::parse(
954 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\nBEGIN:VEVENT\r\nUID:u\r\n\
955 DTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\n\
956 X-A;ENCODING=BASE64:aGk=\r\nEND:VEVENT\r\nBEGIN:VTODO\r\nUID:t\r\n\
957 DTSTAMP:20260101T000000Z\r\nX-A:hi\r\nEND:VTODO\r\nEND:VCALENDAR\r\n",
958 )
959 .unwrap(),
960 );
961 assert!(
962 two.contains(":aGk=\r\n") && two.contains("X-A:hi\r\n"),
963 "{two}"
964 );
965}
966