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 // The parser accepts a body cut off before its END, and the store serves
52 // the body as it came.
53 let last = text.lines().rev().find(|l| !l.trim().is_empty());
54 if !last.is_some_and(|l| l.trim().eq_ignore_ascii_case("END:VCALENDAR")) {
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_many_rules(&cal) {
75 return Err(Invalid::CalendarResource);
76 }
77 let scheduled = |t: &ICalendarComponentType| {
78 matches!(
79 t,
80 ICalendarComponentType::VEvent
81 | ICalendarComponentType::VTodo
82 | ICalendarComponentType::VJournal
83 )
84 };
85 let top = root
86 .component_ids
87 .iter()
88 .filter_map(|&id| cal.components.get(id as usize));
89 // One nested inside another escapes the one-UID check below.
90 let all = cal
91 .components
92 .iter()
93 .filter(|c| scheduled(&c.component_type));
94 if all.count() != top.clone().filter(|c| scheduled(&c.component_type)).count() {
95 return Err(Invalid::CalendarResource);
96 }
97 let mut found: Option<CalendarObject> = None;
98 let mut masters = 0;
99 for c in top {
100 let component = match c.component_type {
101 ICalendarComponentType::VTimezone => continue,
102 ICalendarComponentType::VEvent => "VEVENT",
103 ICalendarComponentType::VTodo => "VTODO",
104 ICalendarComponentType::VJournal => "VJOURNAL",
105 _ => return Err(Invalid::CalendarComponent),
106 };
107 // RFC 5545 requires DTSTART on a VEVENT without METHOD, and on a
108 // VTODO with DURATION.
109 let needs_start = match component {
110 "VEVENT" => true,
111 "VTODO" => c.has_property(&ICalendarProperty::Duration),
112 _ => false,
113 };
114 if needs_start && !c.has_property(&ICalendarProperty::Dtstart) {
115 return Err(Invalid::CalendarData);
116 }
117 let uid = c
118 .uid()
119 .filter(|u| !u.trim().is_empty())
120 .ok_or(Invalid::CalendarResource)?;
121 if !c.has_property(&ICalendarProperty::RecurrenceId) {
122 masters += 1;
123 if masters > 1 {
124 return Err(Invalid::CalendarResource);
125 }
126 }
127 match &found {
128 Some(f) if f.uid != uid || f.component != component => {
129 return Err(Invalid::CalendarResource);
130 }
131 Some(_) => {}
132 None => {
133 found = Some(CalendarObject {
134 uid: uid.to_string(),
135 component,
136 })
137 }
138 }
139 }
140 let found = found.ok_or(Invalid::CalendarResource)?;
141 if !supported.contains(&found.component) {
142 return Err(Invalid::CalendarComponent);
143 }
144 Ok(found)
145}
146
147/// Levels below VCALENDAR, as in VEVENT > PARTICIPANT > VLOCATION, with
148/// room to spare. Scheduling and rendering recurse once per level.
149const MAX_NESTING: usize = 4;
150
151fn too_deep(cal: &ICalendar) -> bool {
152 let mut stack = vec![(0, 0)];
153 while let Some((i, depth)) = stack.pop() {
154 if depth > MAX_NESTING {
155 return true;
156 }
157 let ids = cal.components.get(i).map_or(&[][..], |c| &c.component_ids);
158 stack.extend(
159 ids.iter()
160 .map(|&id| id as usize)
161 .filter(|&id| id > i)
162 .map(|id| (id, depth + 1)),
163 );
164 }
165 false
166}
167
168/// A rule that never matches costs up to 25 ms per expansion. Exported
169/// VTIMEZONEs can hold dozens of observances.
170const MAX_RULES: usize = 4;
171const MAX_ZONE_RULES: usize = 50;
172/// A rule with COUNT expands from DTSTART on every query.
173const MAX_COUNT: u32 = 100_000;
174const MAX_COUNT_SUB_DAILY: u32 = 10_000;
175
176fn too_many_rules(cal: &ICalendar) -> bool {
177 let mut zone_rules = 0;
178 for c in &cal.components {
179 let rules: Vec<_> = c
180 .entries
181 .iter()
182 .filter(|e| matches!(e.name, ICalendarProperty::Rrule | ICalendarProperty::Exrule))
183 .collect();
184 let costly = rules.iter().any(|e| match e.values.first() {
185 Some(ICalendarValue::RecurrenceRule(r)) => r.count.is_some_and(|n| {
186 n > match r.freq {
187 ICalendarFrequency::Secondly
188 | ICalendarFrequency::Minutely
189 | ICalendarFrequency::Hourly => MAX_COUNT_SUB_DAILY,
190 _ => MAX_COUNT,
191 }
192 }),
193 _ => false,
194 });
195 if costly {
196 return true;
197 }
198 let rules = rules.len();
199 match c.component_type {
200 ICalendarComponentType::Standard | ICalendarComponentType::Daylight => {
201 zone_rules += rules
202 }
203 _ if rules > MAX_RULES => return true,
204 _ => {}
205 }
206 }
207 zone_rules > MAX_ZONE_RULES
208}
209
210/// Checks an address object resource (RFC 6352, 5.1) and returns its UID. A
211/// card without one is accepted: several clients omit it.
212pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> {
213 let text = std::str::from_utf8(body).map_err(|_| Invalid::AddressData)?;
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/// `data` with `DTSTAMP:<now>` inserted after the BEGIN line of each VEVENT,
228/// VTODO, VJOURNAL and VFREEBUSY that lacks it (RFC 5545 requires it).
229/// Inserts text instead of re-serializing, so every other byte stays.
230/// `None` if nothing was missing.
231pub fn with_dtstamp(data: &[u8], now: DateTime<Utc>) -> Option<Vec<u8>> {
232 const STAMPED: [&[u8]; 4] = [b"VEVENT", b"VTODO", b"VJOURNAL", b"VFREEBUSY"];
233 let lines: Vec<&[u8]> = data.split_inclusive(|&b| b == b'\n').collect();
234 // (component, index of its BEGIN line, has DTSTAMP)
235 let mut open: Vec<(&[u8], usize, bool)> = Vec::new();
236 let mut missing = HashSet::new();
237 for (i, line) in lines.iter().enumerate() {
238 if line.first().is_some_and(|b| *b == b' ' || *b == b'\t') {
239 continue;
240 }
241 let line = line.trim_ascii_end();
242 let name_end = line
243 .iter()
244 .position(|b| *b == b':' || *b == b';')
245 .unwrap_or(line.len());
246 let (name, value) = (
247 &line[..name_end],
248 line.get(name_end + 1..).unwrap_or_default(),
249 );
250 if name.eq_ignore_ascii_case(b"BEGIN") {
251 open.push((value, i, false));
252 } else if name.eq_ignore_ascii_case(b"END") {
253 if let Some((comp, begin, false)) = open.pop()
254 && STAMPED.iter().any(|s| comp.eq_ignore_ascii_case(s))
255 {
256 missing.insert(begin);
257 }
258 } else if name.eq_ignore_ascii_case(b"DTSTAMP")
259 && let Some(top) = open.last_mut()
260 {
261 top.2 = true;
262 }
263 }
264 if missing.is_empty() {
265 return None;
266 }
267 let stamp = now.format("DTSTAMP:%Y%m%dT%H%M%SZ").to_string();
268 let mut out = Vec::with_capacity(data.len() + missing.len() * 28);
269 for (i, line) in lines.iter().enumerate() {
270 out.extend_from_slice(line);
271 if missing.contains(&i) {
272 let lf_only = line.ends_with(b"\n") && !line.ends_with(b"\r\n");
273 let eol: &[u8] = if lf_only { b"\n" } else { b"\r\n" };
274 if !line.ends_with(b"\n") {
275 out.extend_from_slice(eol);
276 }
277 out.extend_from_slice(stamp.as_bytes());
278 out.extend_from_slice(eol);
279 }
280 }
281 Some(out)
282}
283