object.rs
| 1 | //! Validation of the calendar and address objects clients PUT. |
| 2 | |
| 3 | use calcard::icalendar::{ICalendar, ICalendarComponentType, ICalendarProperty}; |
| 4 | use calcard::{Entry, Parser}; |
| 5 | use chrono::{DateTime, Utc}; |
| 6 | use xmltree::Element; |
| 7 | |
| 8 | use 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)] |
| 12 | pub 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 | |
| 24 | impl 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)] |
| 37 | pub 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. |
| 45 | pub 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. |
| 113 | const MAX_NESTING: usize = 4; |
| 114 | |
| 115 | fn 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. |
| 134 | const MAX_RULES: usize = 4; |
| 135 | const MAX_ZONE_RULES: usize = 50; |
| 136 | |
| 137 | fn 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. |
| 158 | pub 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. |
| 174 | pub 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 |