principal.rs
⎇
Raw
1//! Principals: attendee search (RFC 3744 9.4 and Apple's
2//! calendarserver-principal-search) and the system address book.
3
4use xmltree::Element;
5
6use crate::filter::{Collation, MatchType, TextMatch};
7use crate::report::Refused;
8use crate::xml::{CALDAV, CALSERVER, DAV, Name, Propfind, child, elements, text};
9
10/// The `calendar-user-type` of a principal.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum UserType {
13 Individual,
14 Room,
15 Resource,
16}
17
18impl UserType {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 UserType::Individual => "INDIVIDUAL",
22 UserType::Room => "ROOM",
23 UserType::Resource => "RESOURCE",
24 }
25 }
26}
27
28/// What a search can see of a principal.
29#[derive(Debug, Clone)]
30pub struct Principal<'a> {
31 /// The URL segment.
32 pub name: &'a str,
33 pub display: &'a str,
34 /// The calendar user addresses, `mailto:` ones included.
35 pub addresses: &'a [String],
36 pub kind: UserType,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct Search {
41 pub terms: Vec<Term>,
42 /// `allof`: every term must match. Otherwise one is enough.
43 pub all: bool,
44 /// Only principals of this type, from a calendarserver search context.
45 pub kind: Option<UserType>,
46 pub find: Propfind,
47 pub limit: Option<usize>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct Term {
52 /// The properties the text is looked for in. Empty means all searchable
53 /// ones and the name.
54 pub props: Vec<Name>,
55 pub text: TextMatch,
56}
57
58/// The properties `principal-search-property-set` offers.
59pub const SEARCHABLE: [(&str, &str, &str); 3] = [
60 (DAV, "displayname", "Display name"),
61 (CALDAV, "calendar-user-address-set", "Calendar user address"),
62 (CALDAV, "calendar-user-type", "Calendar user type"),
63];
64
65impl Search {
66 pub fn matches(&self, p: &Principal) -> bool {
67 if self.kind.is_some_and(|k| k != p.kind) {
68 return false;
69 }
70 let hit = |t: &Term| {
71 let any = t.props.is_empty();
72 let wants = |ns: &str, local: &str| any || t.props.iter().any(|n| n.is(ns, local));
73 (any && t.text.matches(p.name))
74 || (wants(DAV, "displayname") && t.text.matches(p.display))
75 || (wants(CALDAV, "calendar-user-type") && t.text.matches(p.kind.as_str()))
76 || ((wants(CALDAV, "calendar-user-address-set")
77 || wants(CALSERVER, "email-address-set"))
78 && p.addresses.iter().any(|a| {
79 t.text.matches(a)
80 || a.strip_prefix("mailto:").is_some_and(|m| t.text.matches(m))
81 }))
82 };
83 // No terms lists every principal.
84 match self.all || self.terms.is_empty() {
85 true => self.terms.iter().all(hit),
86 false => self.terms.iter().any(hit),
87 }
88 }
89}
90
91fn term_text(e: &Element) -> Result<TextMatch, Refused> {
92 let match_type = match e.attributes.get("match-type").map(String::as_str) {
93 None | Some("contains") => MatchType::Contains,
94 Some("starts-with") => MatchType::StartsWith,
95 Some("ends-with") => MatchType::EndsWith,
96 Some("equals") => MatchType::Equals,
97 Some(_) => return Err(Refused::Invalid),
98 };
99 Ok(TextMatch {
100 text: text(e),
101 collation: Collation::UnicodeCasemap,
102 match_type,
103 negate: false,
104 })
105}
106
107/// The properties in `<prop>`, and any stray property element beside it:
108/// python-caldav puts them there.
109fn prop_names(root: &Element) -> Propfind {
110 let mut names: Vec<Name> = child(root, DAV, "prop")
111 .map(|p| elements(p).map(Name::of).collect())
112 .unwrap_or_default();
113 let structure = [
114 (DAV, "prop"),
115 (DAV, "property-search"),
116 (DAV, "apply-to-principal-collection-set"),
117 (DAV, "limit"),
118 (CALSERVER, "search-token"),
119 (CALSERVER, "limit"),
120 ];
121 names.extend(
122 elements(root)
123 .map(Name::of)
124 .filter(|n| !structure.iter().any(|(ns, l)| n.is(ns, l))),
125 );
126 Propfind::Prop(names)
127}
128
129fn nresults(root: &Element, ns: &str) -> Result<Option<usize>, Refused> {
130 child(root, ns, "limit")
131 .and_then(|l| child(l, ns, "nresults"))
132 .map(|n| text(n).parse().map_err(|_| Refused::Invalid))
133 .transpose()
134}
135
136/// A `DAV:principal-property-search` body.
137pub fn property_search(root: &Element) -> Result<Search, Refused> {
138 let mut terms = Vec::new();
139 for s in elements(root).filter(|e| Name::of(e).is(DAV, "property-search")) {
140 let props = child(s, DAV, "prop")
141 .map(|p| elements(p).map(Name::of).collect())
142 .unwrap_or_default();
143 let text = term_text(child(s, DAV, "match").ok_or(Refused::Invalid)?)?;
144 terms.push(Term { props, text });
145 }
146 Ok(Search {
147 terms,
148 all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
149 kind: None,
150 find: prop_names(root),
151 limit: nresults(root, DAV)?,
152 })
153}
154
155/// A `calendarserver-principal-search` body. Every token must match one of
156/// the name, the display name or an address.
157pub fn calendarserver_search(root: &Element) -> Result<Search, Refused> {
158 let terms: Vec<Term> = elements(root)
159 .filter(|e| Name::of(e).is(CALSERVER, "search-token"))
160 .map(|e| {
161 term_text(e).map(|text| Term {
162 props: Vec::new(),
163 text,
164 })
165 })
166 .collect::<Result<_, _>>()?;
167 let kind = match root.attributes.get("context").map(String::as_str) {
168 Some("location") => Some(UserType::Room),
169 Some("resource") => Some(UserType::Resource),
170 Some("user") => Some(UserType::Individual),
171 _ => None,
172 };
173 Ok(Search {
174 terms,
175 all: root.attributes.get("test").map(String::as_str) != Some("anyof"),
176 kind,
177 find: prop_names(root),
178 limit: nresults(root, CALSERVER)?,
179 })
180}
181
182/// The system address book's vCard of a principal.
183pub fn card(uid: &str, p: &Principal, email: &str) -> String {
184 let kind = match p.kind {
185 UserType::Individual => "individual",
186 UserType::Room => "location",
187 // RFC 6869.
188 UserType::Resource => "device",
189 };
190 let fn_ = escape(p.display);
191 format!(
192 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{uid}\r\nFN:{fn_}\r\nN:{fn_};;;;\r\nEMAIL;TYPE=INTERNET:{}\r\nKIND:{kind}\r\nEND:VCARD\r\n",
193 escape(email)
194 )
195}
196
197fn escape(s: &str) -> String {
198 let mut out = String::with_capacity(s.len());
199 for c in s.chars() {
200 match c {
201 '\\' | ',' | ';' => {
202 out.push('\\');
203 out.push(c);
204 }
205 '\n' => out.push_str("\\n"),
206 '\r' => {}
207 _ => out.push(c),
208 }
209 }
210 out
211}
212