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