object.rs
⎇
Raw
1//! Validation of the calendar and address objects clients PUT.
2
3use std::collections::HashSet;
4
5use calcard::icalendar::{
6 ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarFrequency, ICalendarProperty,
7 ICalendarValue,
8};
9use calcard::{Entry, Parser};
10use chrono::{DateTime, Utc};
11use xmltree::Element;
12
13use crate::expand::is_item;
14use crate::text::{logical_lines, name, unfold, value};
15use crate::xml::{CALDAV, CARDDAV, el};
16
17/// Why a PUT body is refused, as the precondition the RFCs name.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Invalid {
20 /// Not parseable as iCalendar, or missing a property RFC 5545 requires.
21 CalendarData,
22 /// Parseable, but not one CalDAV object: several UIDs, mixed component
23 /// types, a METHOD, or no component at all.
24 CalendarResource,
25 /// A component type the collection does not take.
26 CalendarComponent,
27 /// Not parseable as one vCard.
28 AddressData,
29}
30
31impl Invalid {
32 pub fn condition(self) -> Element {
33 match self {
34 Invalid::CalendarData => el(CALDAV, "valid-calendar-data"),
35 Invalid::CalendarResource => el(CALDAV, "valid-calendar-object-resource"),
36 Invalid::CalendarComponent => el(CALDAV, "supported-calendar-component"),
37 Invalid::AddressData => el(CARDDAV, "valid-address-data"),
38 }
39 }
40}
41
42/// What the store needs to know about a valid calendar object.
43#[derive(Debug, PartialEq, Eq)]
44pub struct CalendarObject {
45 pub uid: String,
46 /// `VEVENT`, `VTODO` or `VJOURNAL`.
47 pub component: &'static str,
48}
49
50/// Checks a calendar object resource (RFC 4791, 4.1). `supported` lists the
51/// component types the collection takes.
52pub fn calendar(body: &[u8], supported: &[&str]) -> Result<CalendarObject, Invalid> {
53 let text = std::str::from_utf8(body).map_err(|_| Invalid::CalendarData)?;
54 if !ends_with(text, "END:VCALENDAR") || !balanced(text) {
55 return Err(Invalid::CalendarData);
56 }
57 let mut parser = Parser::new(text);
58 let Entry::ICalendar(cal) = parser.entry() else {
59 return Err(Invalid::CalendarData);
60 };
61 if !matches!(parser.entry(), Entry::Eof) {
62 return Err(Invalid::CalendarResource);
63 }
64 let root = cal.components.first().ok_or(Invalid::CalendarData)?;
65 if root.component_type != ICalendarComponentType::VCalendar {
66 return Err(Invalid::CalendarData);
67 }
68 if root.has_property(&ICalendarProperty::Method) {
69 return Err(Invalid::CalendarResource);
70 }
71 if too_deep(&cal) {
72 return Err(Invalid::CalendarData);
73 }
74 if too_costly(&cal) {
75 return Err(Invalid::CalendarResource);
76 }
77 let top = root
78 .component_ids
79 .iter()
80 .filter_map(|&id| cal.components.get(id as usize));
81 // One nested inside another escapes the one-UID check below.
82 let all = cal.components.iter().filter(|c| is_item(c));
83 if all.count() != top.clone().filter(|c| is_item(c)).count() {
84 return Err(Invalid::CalendarResource);
85 }
86 let mut found: Option<CalendarObject> = None;
87 let mut masters = 0;
88 for c in top {
89 let component = match c.component_type {
90 ICalendarComponentType::VTimezone => continue,
91 ICalendarComponentType::VEvent => "VEVENT",
92 ICalendarComponentType::VTodo => "VTODO",
93 ICalendarComponentType::VJournal => "VJOURNAL",
94 _ => return Err(Invalid::CalendarComponent),
95 };
96 // RFC 5545 requires DTSTART on a VEVENT without METHOD, and on a
97 // VTODO with DURATION.
98 let needs_start = match component {
99 "VEVENT" => true,
100 "VTODO" => c.has_property(&ICalendarProperty::Duration),
101 _ => false,
102 };
103 if needs_start && !c.has_property(&ICalendarProperty::Dtstart) {
104 return Err(Invalid::CalendarData);
105 }
106 let uid = c
107 .uid()
108 .filter(|u| !u.trim().is_empty())
109 .ok_or(Invalid::CalendarResource)?;
110 if !c.has_property(&ICalendarProperty::RecurrenceId) {
111 masters += 1;
112 if masters > 1 {
113 return Err(Invalid::CalendarResource);
114 }
115 }
116 match &found {
117 Some(f) if f.uid != uid || f.component != component => {
118 return Err(Invalid::CalendarResource);
119 }
120 Some(_) => {}
121 None => {
122 found = Some(CalendarObject {
123 uid: uid.to_string(),
124 component,
125 })
126 }
127 }
128 }
129 let found = found.ok_or(Invalid::CalendarResource)?;
130 if !supported.contains(&found.component) {
131 return Err(Invalid::CalendarComponent);
132 }
133 Ok(found)
134}
135
136/// Levels below VCALENDAR, as in VEVENT > PARTICIPANT > VLOCATION, with
137/// room to spare. Scheduling and rendering recurse once per level.
138const MAX_NESTING: usize = 4;
139
140fn too_deep(cal: &ICalendar) -> bool {
141 let mut stack = vec![(0, 0)];
142 while let Some((i, depth)) = stack.pop() {
143 if depth > MAX_NESTING {
144 return true;
145 }
146 let ids = cal.components.get(i).map_or(&[][..], |c| &c.component_ids);
147 stack.extend(
148 ids.iter()
149 .map(|&id| id as usize)
150 .filter(|&id| id > i)
151 .map(|id| (id, depth + 1)),
152 );
153 }
154 false
155}
156
157/// A rule that never matches costs up to 25 ms per expansion. Exported
158/// VTIMEZONEs can hold dozens of observances.
159const MAX_RULES: usize = 4;
160const MAX_ZONE_RULES: usize = 50;
161const MAX_ZONES: usize = 50;
162const MAX_ALL_ZONE_RULES: usize = 500;
163/// A rule with COUNT expands from DTSTART on every query.
164const MAX_COUNT: u32 = 100_000;
165const MAX_COUNT_SUB_DAILY: u32 = 10_000;
166/// Scheduling compares attendees and components pairwise.
167const MAX_ATTENDEES: usize = 2000;
168pub(crate) const MAX_COMPONENTS: usize = 4000;
169/// Card views look up labels and groups across lines.
170const MAX_CARD_LINES: usize = 10_000;
171
172fn too_costly(cal: &ICalendar) -> bool {
173 let rules = |c: &ICalendarComponent| {
174 c.entries
175 .iter()
176 .filter(|e| matches!(e.name, ICalendarProperty::Rrule | ICalendarProperty::Exrule))
177 .count()
178 };
179 let observance = |c: &&ICalendarComponent| {
180 matches!(
181 c.component_type,
182 ICalendarComponentType::Standard | ICalendarComponentType::Daylight
183 )
184 };
185 let zones = cal
186 .components
187 .iter()
188 .filter(|c| c.component_type == ICalendarComponentType::VTimezone)
189 .count();
190 let zone_rules: usize = cal.components.iter().filter(observance).map(rules).sum();
191 if cal.components.len() > MAX_COMPONENTS || zones > MAX_ZONES || zone_rules > MAX_ALL_ZONE_RULES
192 {
193 return true;
194 }
195 cal.components.iter().any(|c| {
196 let costly = c.entries.iter().any(|e| match (&e.name, e.values.first()) {
197 (
198 ICalendarProperty::Rrule | ICalendarProperty::Exrule,
199 Some(ICalendarValue::RecurrenceRule(r)),
200 ) => r.count.is_some_and(|n| {
201 n > match r.freq {
202 ICalendarFrequency::Secondly
203 | ICalendarFrequency::Minutely
204 | ICalendarFrequency::Hourly => MAX_COUNT_SUB_DAILY,
205 _ => MAX_COUNT,
206 }
207 }),
208 _ => false,
209 });
210 let attendees = c
211 .entries
212 .iter()
213 .filter(|e| e.name == ICalendarProperty::Attendee)
214 .count();
215 costly
216 || attendees > MAX_ATTENDEES
217 || match c.component_type {
218 ICalendarComponentType::VTimezone => {
219 c.component_ids
220 .iter()
221 .filter_map(|&id| cal.components.get(id as usize))
222 .map(rules)
223 .sum::<usize>()
224 > MAX_ZONE_RULES
225 }
226 ICalendarComponentType::Standard | ICalendarComponentType::Daylight => false,
227 _ => rules(c) > MAX_RULES,
228 }
229 })
230}
231
232/// Checks an address object resource (RFC 6352, 5.1) and returns its UID. A
233/// card without one is accepted: several clients omit it.
234pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
235 let text = std::str::from_utf8(body).map_err(|_| Invalid::AddressData)?;
236 if !ends_with(text, "END:VCARD") || logical_lines(text).len() > MAX_CARD_LINES {
237 return Err(Invalid::AddressData);
238 }
239 let mut parser = Parser::new(text);
240 let Entry::VCard(card) = parser.entry() else {
241 return Err(Invalid::AddressData);
242 };
243 if !matches!(parser.entry(), Entry::Eof) {
244 return Err(Invalid::AddressData);
245 }
246 Ok(card
247 .uid()
248 .filter(|u| !u.trim().is_empty())
249 .map(str::to_string))
250}
251
252/// Whether the last line of `text` is `end`. The parser accepts a body cut
253/// off before its END, and the store serves the body as it came.
254fn ends_with(text: &str, end: &str) -> bool {
255 text.lines()
256 .rev()
257 .find(|l| !l.trim().is_empty())
258 .is_some_and(|l| l.trim().eq_ignore_ascii_case(end))
259}
260
261/// Whether every BEGIN has its END. The parser lets END:VCALENDAR close a
262/// VEVENT cut off before its own END.
263fn balanced(text: &str) -> bool {
264 let mut open: Vec<String> = Vec::new();
265 for line in logical_lines(text) {
266 let n = name(line);
267 if n != "BEGIN" && n != "END" {
268 continue;
269 }
270 let what = value(&unfold(line)).trim().to_ascii_uppercase();
271 match n.as_str() {
272 "BEGIN" => open.push(what),
273 _ if open.pop().as_ref() != Some(&what) => return false,
274 _ => {}
275 }
276 }
277 open.is_empty()
278}
279
280/// `data` with `DTSTAMP:<now>` inserted after the BEGIN line of each VEVENT,
281/// VTODO, VJOURNAL and VFREEBUSY that lacks it (RFC 5545 requires it).
282/// Inserts text instead of re-serializing, so every other byte stays.
283/// `None` if nothing was missing.
284pub fn with_dtstamp(data: &[u8], now: DateTime<Utc>) -> Option<Vec<u8>> {
285 const STAMPED: [&[u8]; 4] = [b"VEVENT", b"VTODO", b"VJOURNAL", b"VFREEBUSY"];
286 let lines: Vec<&[u8]> = data.split_inclusive(|&b| b == b'\n').collect();
287 // (component, index of its BEGIN line, has DTSTAMP)
288 let mut open: Vec<(&[u8], usize, bool)> = Vec::new();
289 let mut missing = HashSet::new();
290 for (i, line) in lines.iter().enumerate() {
291 if line.first().is_some_and(|b| *b == b' ' || *b == b'\t') {
292 continue;
293 }
294 let line = line.trim_ascii_end();
295 let name_end = line
296 .iter()
297 .position(|b| *b == b':' || *b == b';')
298 .unwrap_or(line.len());
299 let (name, value) = (
300 &line[..name_end],
301 line.get(name_end + 1..).unwrap_or_default(),
302 );
303 if name.eq_ignore_ascii_case(b"BEGIN") {
304 open.push((value, i, false));
305 } else if name.eq_ignore_ascii_case(b"END") {
306 if let Some((comp, begin, false)) = open.pop()
307 && STAMPED.iter().any(|s| comp.eq_ignore_ascii_case(s))
308 {
309 missing.insert(begin);
310 }
311 } else if name.eq_ignore_ascii_case(b"DTSTAMP")
312 && let Some(top) = open.last_mut()
313 {
314 top.2 = true;
315 }
316 }
317 if missing.is_empty() {
318 return None;
319 }
320 let stamp = now.format("DTSTAMP:%Y%m%dT%H%M%SZ").to_string();
321 let mut out = Vec::with_capacity(data.len() + missing.len() * 28);
322 for (i, line) in lines.iter().enumerate() {
323 out.extend_from_slice(line);
324 if missing.contains(&i) {
325 let lf_only = line.ends_with(b"\n") && !line.ends_with(b"\r\n");
326 let eol: &[u8] = if lf_only { b"\n" } else { b"\r\n" };
327 if !line.ends_with(b"\n") {
328 out.extend_from_slice(eol);
329 }
330 out.extend_from_slice(stamp.as_bytes());
331 out.extend_from_slice(eol);
332 }
333 }
334 Some(out)
335}
336