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