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