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