filter.rs
⎇
Raw
1//! Query filters: `calendar-query` (RFC 4791, 9.7) and `addressbook-query`
2//! (RFC 6352, 10.5). Parsing and evaluation.
3
4use std::borrow::Cow;
5use std::ops::Range;
6
7use calcard::icalendar::{
8 ICalendar, ICalendarComponentType, ICalendarEntry, ICalendarParameterName,
9 ICalendarParameterValue, ICalendarProperty, ICalendarRelated, ICalendarValue,
10};
11use calcard::vcard::{VCard, VCardVersion};
12use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc};
13use unicode_normalization::UnicodeNormalization;
14use xmltree::Element;
15
16use crate::expand::{expand, stamp};
17use crate::freebusy::periods;
18use crate::report::Refused;
19use crate::xml::{CALDAV, CARDDAV, Name, child, elements, text};
20use crate::zone::{Zone, Zones, add};
21
22pub type TimeRange = Range<DateTime<Utc>>;
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct CompFilter {
26 /// Upper case.
27 pub name: String,
28 pub not_defined: bool,
29 pub time_range: Option<TimeRange>,
30 pub props: Vec<PropFilter>,
31 pub comps: Vec<CompFilter>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct PropFilter {
36 pub name: String,
37 pub not_defined: bool,
38 pub time_range: Option<TimeRange>,
39 pub text: Option<TextMatch>,
40 pub params: Vec<ParamFilter>,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct ParamFilter {
45 pub name: String,
46 pub not_defined: bool,
47 pub text: Option<TextMatch>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct CardFilter {
52 /// `test="allof"`. The default is `anyof`.
53 pub all: bool,
54 pub props: Vec<CardPropFilter>,
55}
56
57#[derive(Debug, Clone, PartialEq)]
58pub struct CardPropFilter {
59 pub name: String,
60 pub all: bool,
61 pub not_defined: bool,
62 pub texts: Vec<TextMatch>,
63 pub params: Vec<ParamFilter>,
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct TextMatch {
68 pub text: String,
69 pub collation: Collation,
70 pub match_type: MatchType,
71 pub negate: bool,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Collation {
76 Octet,
77 AsciiCasemap,
78 UnicodeCasemap,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum MatchType {
83 Equals,
84 Contains,
85 StartsWith,
86 EndsWith,
87}
88
89impl Collation {
90 fn fold<'a>(self, s: &'a str) -> Cow<'a, str> {
91 match self {
92 Collation::Octet => Cow::Borrowed(s),
93 Collation::AsciiCasemap => Cow::Owned(s.to_ascii_lowercase()),
94 // Lowercase after NFKD, so compatibility forms such as U+FB01
95 // (the "fi" ligature) fold too.
96 Collation::UnicodeCasemap => {
97 Cow::Owned(s.nfkd().flat_map(char::to_lowercase).collect())
98 }
99 }
100 }
101}
102
103impl TextMatch {
104 /// `ns` picks the defaults: CalDAV folds ASCII only and CardDAV Unicode.
105 fn parse(e: &Element, ns: &str) -> Result<Self, Refused> {
106 let collation = match e.attributes.get("collation").map(String::as_str) {
107 None if ns == CARDDAV => Collation::UnicodeCasemap,
108 None => Collation::AsciiCasemap,
109 Some("i;octet") => Collation::Octet,
110 Some("i;ascii-casemap") => Collation::AsciiCasemap,
111 Some("i;unicode-casemap") => Collation::UnicodeCasemap,
112 Some(_) => return Err(Refused::Condition(Name::new(ns, "supported-collation"))),
113 };
114 let match_type = match e.attributes.get("match-type").map(String::as_str) {
115 None | Some("contains") => MatchType::Contains,
116 Some("equals") => MatchType::Equals,
117 Some("starts-with") => MatchType::StartsWith,
118 Some("ends-with") => MatchType::EndsWith,
119 Some(_) => return Err(Refused::Invalid),
120 };
121 Ok(TextMatch {
122 text: text(e),
123 collation,
124 match_type,
125 negate: e.attributes.get("negate-condition").map(String::as_str) == Some("yes"),
126 })
127 }
128
129 pub fn matches(&self, value: &str) -> bool {
130 let (v, t) = (self.collation.fold(value), self.collation.fold(&self.text));
131 let hit = match self.match_type {
132 MatchType::Equals => v == t,
133 MatchType::Contains => v.contains(&*t),
134 MatchType::StartsWith => v.starts_with(&*t),
135 MatchType::EndsWith => v.ends_with(&*t),
136 };
137 hit != self.negate
138 }
139}
140
141// ---------------------------------------------------------------------------
142// Parsing
143// ---------------------------------------------------------------------------
144
145/// The `<C:filter>` of a calendar-query.
146pub fn calendar_filter(e: &Element) -> Result<CompFilter, Refused> {
147 let root = comp_filter(child(e, CALDAV, "comp-filter").ok_or(Refused::Invalid)?)?;
148 if root.name != "VCALENDAR" {
149 return Err(Refused::Condition(Name::new(CALDAV, "valid-filter")));
150 }
151 Ok(root)
152}
153
154fn comp_filter(e: &Element) -> Result<CompFilter, Refused> {
155 let mut f = CompFilter {
156 name: name_attr(e)?.to_ascii_uppercase(),
157 not_defined: false,
158 time_range: None,
159 props: Vec::new(),
160 comps: Vec::new(),
161 };
162 for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) {
163 match c.name.as_str() {
164 "is-not-defined" => f.not_defined = true,
165 "time-range" => f.time_range = Some(time_range(c)?),
166 "prop-filter" => f.props.push(prop_filter(c)?),
167 "comp-filter" => f.comps.push(comp_filter(c)?),
168 _ => {}
169 }
170 }
171 Ok(f)
172}
173
174fn prop_filter(e: &Element) -> Result<PropFilter, Refused> {
175 let mut f = PropFilter {
176 name: name_attr(e)?.to_string(),
177 not_defined: false,
178 time_range: None,
179 text: None,
180 params: Vec::new(),
181 };
182 for c in elements(e).filter(|c| Name::of(c).ns == CALDAV) {
183 match c.name.as_str() {
184 "is-not-defined" => f.not_defined = true,
185 "time-range" => f.time_range = Some(time_range(c)?),
186 "text-match" => f.text = Some(TextMatch::parse(c, CALDAV)?),
187 "param-filter" => f.params.push(param_filter(c, CALDAV)?),
188 _ => {}
189 }
190 }
191 Ok(f)
192}
193
194fn param_filter(e: &Element, ns: &str) -> Result<ParamFilter, Refused> {
195 let mut f = ParamFilter {
196 name: name_attr(e)?.to_string(),
197 not_defined: false,
198 text: None,
199 };
200 for c in elements(e).filter(|c| Name::of(c).ns == ns) {
201 match c.name.as_str() {
202 "is-not-defined" => f.not_defined = true,
203 "text-match" => f.text = Some(TextMatch::parse(c, ns)?),
204 _ => {}
205 }
206 }
207 Ok(f)
208}
209
210/// The `<CR:filter>` of an addressbook-query.
211pub fn card_filter(e: &Element) -> Result<CardFilter, Refused> {
212 let props = elements(e)
213 .filter(|c| Name::of(c).is(CARDDAV, "prop-filter"))
214 .map(card_prop_filter)
215 .collect::<Result<_, _>>()?;
216 Ok(CardFilter {
217 all: all_of(e)?,
218 props,
219 })
220}
221
222fn card_prop_filter(e: &Element) -> Result<CardPropFilter, Refused> {
223 let mut f = CardPropFilter {
224 name: name_attr(e)?.to_string(),
225 all: all_of(e)?,
226 not_defined: false,
227 texts: Vec::new(),
228 params: Vec::new(),
229 };
230 for c in elements(e).filter(|c| Name::of(c).ns == CARDDAV) {
231 match c.name.as_str() {
232 "is-not-defined" => f.not_defined = true,
233 "text-match" => f.texts.push(TextMatch::parse(c, CARDDAV)?),
234 "param-filter" => f.params.push(param_filter(c, CARDDAV)?),
235 _ => {}
236 }
237 }
238 Ok(f)
239}
240
241fn all_of(e: &Element) -> Result<bool, Refused> {
242 match e.attributes.get("test").map(String::as_str) {
243 None | Some("anyof") => Ok(false),
244 Some("allof") => Ok(true),
245 Some(_) => Err(Refused::Invalid),
246 }
247}
248
249fn name_attr(e: &Element) -> Result<&str, Refused> {
250 e.attributes
251 .get("name")
252 .map(String::as_str)
253 .ok_or(Refused::Invalid)
254}
255
256/// A `time-range` or `expand` element. A missing bound is open.
257pub fn time_range(e: &Element) -> Result<TimeRange, Refused> {
258 let at = |k: &str| {
259 e.attributes
260 .get(k)
261 .map(|v| {
262 NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%SZ")
263 .map(|t| t.and_utc())
264 .map_err(|_| Refused::Invalid)
265 })
266 .transpose()
267 };
268 let start = at("start")?.unwrap_or(DateTime::<Utc>::MIN_UTC);
269 let end = at("end")?.unwrap_or(DateTime::<Utc>::MAX_UTC);
270 if start >= end {
271 return Err(Refused::Invalid);
272 }
273 Ok(start..end)
274}
275
276// ---------------------------------------------------------------------------
277// Calendar evaluation
278// ---------------------------------------------------------------------------
279
280/// Whether a calendar object matches `filter`. `floating` interprets values
281/// without a zone.
282pub fn matches_calendar(cal: &ICalendar, filter: &CompFilter, floating: &Zone) -> bool {
283 let ctx = Ctx {
284 cal,
285 zones: Zones::new(cal, floating.clone()),
286 floating,
287 };
288 ctx.comp(None, filter)
289}
290
291struct Ctx<'a> {
292 cal: &'a ICalendar,
293 zones: Zones,
294 floating: &'a Zone,
295}
296
297impl Ctx<'_> {
298 fn comp(&self, parent: Option<usize>, f: &CompFilter) -> bool {
299 let children: Vec<usize> = match parent {
300 None => (!self.cal.components.is_empty())
301 .then_some(0)
302 .into_iter()
303 .collect(),
304 Some(p) => self.cal.components[p]
305 .component_ids
306 .iter()
307 .map(|&i| i as usize)
308 .collect(),
309 };
310 let mut found = children.into_iter().filter(|&i| {
311 self.cal.components[i]
312 .component_type
313 .as_str()
314 .eq_ignore_ascii_case(&f.name)
315 });
316 if f.not_defined {
317 return found.next().is_none();
318 }
319 found.any(|i| {
320 f.time_range.as_ref().is_none_or(|r| self.overlaps(i, r))
321 && f.props.iter().all(|p| self.prop(i, p))
322 && f.comps.iter().all(|c| self.comp(Some(i), c))
323 })
324 }
325
326 /// The time-range rules of RFC 4791, 9.9.
327 fn overlaps(&self, i: usize, r: &TimeRange) -> bool {
328 let c = &self.cal.components[i];
329 let has = |p: ICalendarProperty| c.has_property(&p);
330 let time = |p: ICalendarProperty| {
331 let e = c.property(&p)?;
332 stamp(
333 &self.zones,
334 e.values.first()?.as_partial_date_time()?,
335 e.tz_id(),
336 )
337 .map(|s| s.utc())
338 };
339 match c.component_type {
340 ICalendarComponentType::VEvent
341 | ICalendarComponentType::VTodo
342 | ICalendarComponentType::VJournal
343 if has(ICalendarProperty::Dtstart) =>
344 {
345 let todo = c.component_type == ICalendarComponentType::VTodo;
346 let (due, duration) = (
347 has(ICalendarProperty::Due),
348 has(ICalendarProperty::Duration),
349 );
350 // One second wider, so that the exact rules below decide the
351 // instances that only touch the range.
352 let second = TimeDelta::seconds(1);
353 let exp = expand(
354 self.cal,
355 add(r.start, -second)..add(r.end, second),
356 self.floating.clone(),
357 );
358 // Unknown instances may overlap, so the object stays in.
359 if exp.truncated {
360 return true;
361 }
362 exp.instances.iter().filter(|x| x.component == i).any(|x| {
363 let (s, e) = (x.start, x.end);
364 match (todo, due, duration) {
365 (true, _, true) => r.start <= e && (r.end > s || r.end >= e),
366 (true, true, _) => {
367 (r.start < e || r.start <= s) && (r.end > s || r.end >= e)
368 }
369 (true, ..) => r.start <= s && r.end > s,
370 _ if s == e => r.start <= s && r.end > s,
371 _ => r.start < e && r.end > s,
372 }
373 })
374 }
375 ICalendarComponentType::VTodo => match (
376 time(ICalendarProperty::Due),
377 time(ICalendarProperty::Completed),
378 time(ICalendarProperty::Created),
379 ) {
380 (Some(due), _, _) => r.start < due && r.end >= due,
381 (None, Some(done), Some(made)) => {
382 (r.start <= made || r.start <= done) && (r.end >= made || r.end >= done)
383 }
384 (None, Some(done), None) => r.start <= done && r.end >= done,
385 (None, None, Some(made)) => r.end > made,
386 (None, None, None) => true,
387 },
388 ICalendarComponentType::VFreebusy => {
389 let busy: Vec<_> = c
390 .properties(&ICalendarProperty::Freebusy)
391 .flat_map(|e| periods(&self.zones, e))
392 .collect();
393 if !busy.is_empty() {
394 return busy.iter().any(|(s, e)| r.start < *e && r.end > *s);
395 }
396 match (
397 time(ICalendarProperty::Dtstart),
398 time(ICalendarProperty::Dtend),
399 ) {
400 (Some(s), Some(e)) => r.start <= e && r.end > s,
401 _ => false,
402 }
403 }
404 ICalendarComponentType::VAlarm => self.alarm_overlaps(i, r),
405 _ => false,
406 }
407 }
408
409 /// Whether a trigger of the alarm, repetitions included, falls into `r`.
410 /// A relative trigger fires once per instance of the parent component.
411 fn alarm_overlaps(&self, alarm: usize, r: &TimeRange) -> bool {
412 let a = &self.cal.components[alarm];
413 let Some(trigger) = a.property(&ICalendarProperty::Trigger) else {
414 return false;
415 };
416 let repeat = a
417 .property(&ICalendarProperty::Repeat)
418 .and_then(|e| e.values.first()?.as_integer())
419 .unwrap_or(0)
420 .clamp(0, 1000) as i32;
421 let every = match a
422 .property(&ICalendarProperty::Duration)
423 .and_then(|e| e.values.first())
424 {
425 Some(ICalendarValue::Duration(d)) => d.to_time_delta().unwrap_or_default(),
426 _ => TimeDelta::zero(),
427 };
428 let hit = |base: DateTime<Utc>| {
429 (0..=repeat).any(|k| {
430 let t = add(base, every.checked_mul(k).unwrap_or(TimeDelta::MAX));
431 r.start <= t && r.end > t
432 })
433 };
434 match trigger.values.first() {
435 Some(ICalendarValue::PartialDateTime(p)) => {
436 stamp(&self.zones, p, trigger.tz_id()).is_some_and(|s| hit(s.utc()))
437 }
438 Some(ICalendarValue::Duration(d)) => {
439 let Some(offset) = d.to_time_delta() else {
440 return false;
441 };
442 let Some(parent) = self
443 .cal
444 .components
445 .iter()
446 .position(|c| c.component_ids.contains(&(alarm as u32)))
447 else {
448 return false;
449 };
450 let from_end = trigger.parameter(&ICalendarParameterName::Related)
451 == Some(&ICalendarParameterValue::Related(ICalendarRelated::End));
452 let reach = offset
453 .abs()
454 .checked_add(&every.checked_mul(repeat).unwrap_or(TimeDelta::MAX))
455 .unwrap_or(TimeDelta::MAX)
456 .checked_add(&TimeDelta::seconds(1))
457 .unwrap_or(TimeDelta::MAX);
458 let exp = expand(
459 self.cal,
460 add(r.start, -reach)..add(r.end, reach),
461 self.floating.clone(),
462 );
463 if exp.truncated {
464 return true;
465 }
466 exp.instances
467 .iter()
468 .filter(|x| x.component == parent)
469 .any(|x| hit(add(if from_end { x.end } else { x.start }, offset)))
470 }
471 _ => false,
472 }
473 }
474
475 fn prop(&self, i: usize, f: &PropFilter) -> bool {
476 let mut found = self.cal.components[i]
477 .entries
478 .iter()
479 .filter(|e| e.name.as_str().eq_ignore_ascii_case(&f.name));
480 if f.not_defined {
481 return found.next().is_none();
482 }
483 found.any(|e| {
484 let l = ical_line(e);
485 f.time_range.as_ref().is_none_or(|r| {
486 e.values
487 .iter()
488 .filter_map(|v| stamp(&self.zones, v.as_partial_date_time()?, e.tz_id()))
489 .any(|s| r.start <= s.utc() && r.end > s.utc())
490 }) && f.text.as_ref().is_none_or(|t| t.matches(&l.value))
491 && f.params.iter().all(|p| param_matches(&l, p))
492 })
493 }
494}
495
496// ---------------------------------------------------------------------------
497// Card evaluation
498// ---------------------------------------------------------------------------
499
500pub fn matches_card(card: &VCard, f: &CardFilter) -> bool {
501 if f.props.is_empty() {
502 return true;
503 }
504 let v4 = card.version() == Some(VCardVersion::V4_0);
505 let lines: Vec<Line> = card
506 .entries
507 .iter()
508 .map(|e| {
509 let mut s = String::new();
510 let _ = e.write_to(&mut s, v4);
511 line(&s)
512 })
513 .collect();
514 let hit = |p: &CardPropFilter| card_prop(&lines, p);
515 if f.all {
516 f.props.iter().all(hit)
517 } else {
518 f.props.iter().any(hit)
519 }
520}
521
522/// Each text-match and param-filter is tested against every instance of the
523/// property; `test` combines their results.
524fn card_prop(lines: &[Line], f: &CardPropFilter) -> bool {
525 let found: Vec<&Line> = lines
526 .iter()
527 .filter(|l| l.name.eq_ignore_ascii_case(&f.name))
528 .collect();
529 if f.not_defined {
530 return found.is_empty();
531 }
532 if found.is_empty() {
533 return false;
534 }
535 let mut tests = f
536 .texts
537 .iter()
538 .map(|t| found.iter().any(|l| t.matches(&l.value)))
539 .chain(
540 f.params
541 .iter()
542 .map(|p| found.iter().any(|l| param_matches(l, p))),
543 )
544 .peekable();
545 if tests.peek().is_none() {
546 return true;
547 }
548 if f.all {
549 tests.all(|b| b)
550 } else {
551 tests.any(|b| b)
552 }
553}
554
555fn param_matches(l: &Line, f: &ParamFilter) -> bool {
556 match l
557 .params
558 .iter()
559 .find(|(n, _)| n.eq_ignore_ascii_case(&f.name))
560 {
561 None => f.not_defined,
562 Some(_) if f.not_defined => false,
563 Some((_, values)) => f
564 .text
565 .as_ref()
566 .is_none_or(|t| values.iter().any(|v| t.matches(v))),
567 }
568}
569
570// ---------------------------------------------------------------------------
571// Content lines
572// ---------------------------------------------------------------------------
573
574/// A property as text, the way text-match sees it: the group dropped, the
575/// parameter values unquoted, the value unescaped.
576struct Line {
577 name: String,
578 params: Vec<(String, Vec<String>)>,
579 value: String,
580}
581
582fn ical_line(e: &ICalendarEntry) -> Line {
583 let mut s = String::new();
584 let _ = e.write_to(&mut s);
585 line(&s)
586}
587
588/// Parses one content line as calcard's writer produces it.
589fn line(written: &str) -> Line {
590 let unfolded = written.replace("\r\n ", "").replace("\r\n\t", "");
591 let unfolded = unfolded.trim_end_matches(['\r', '\n']);
592 let head = split_unquoted(unfolded, ':')[0];
593 let value = unfolded.get(head.len() + 1..).unwrap_or_default();
594 let mut head = split_unquoted(head, ';').into_iter();
595 let name = head.next().unwrap_or_default();
596 let name = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase();
597 let params = head
598 .filter_map(|p| p.split_once('='))
599 .map(|(k, v)| {
600 let values = split_unquoted(v, ',')
601 .into_iter()
602 .map(|v| v.trim_matches('"').to_string())
603 .collect();
604 (k.to_ascii_uppercase(), values)
605 })
606 .collect();
607 Line {
608 name,
609 params,
610 value: unescape(value),
611 }
612}
613
614fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
615 let mut out = Vec::new();
616 let (mut quoted, mut from) = (false, 0);
617 for (i, c) in s.char_indices() {
618 if c == '"' {
619 quoted = !quoted;
620 } else if c == sep && !quoted {
621 out.push(&s[from..i]);
622 from = i + 1;
623 }
624 }
625 out.push(&s[from..]);
626 out
627}
628
629fn unescape(s: &str) -> String {
630 let mut out = String::with_capacity(s.len());
631 let mut chars = s.chars();
632 while let Some(c) = chars.next() {
633 if c != '\\' {
634 out.push(c);
635 continue;
636 }
637 match chars.next() {
638 Some('n' | 'N') => out.push('\n'),
639 Some(c) => out.push(c),
640 None => out.push('\\'),
641 }
642 }
643 out
644}
645