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