object.rs
| 1 | //! Validation of the calendar and address objects clients PUT. |
| 2 | |
| 3 | use std::collections::HashSet; |
| 4 | |
| 5 | use calcard::icalendar::{ |
| 6 | ICalendar, ICalendarComponentType, ICalendarFrequency, ICalendarProperty, ICalendarValue, |
| 7 | }; |
| 8 | use calcard::{Entry, Parser}; |
| 9 | use chrono::{DateTime, Utc}; |
| 10 | use xmltree::Element; |
| 11 | |
| 12 | use 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)] |
| 16 | pub 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 | |
| 28 | impl 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)] |
| 41 | pub 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. |
| 49 | pub 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. |
| 149 | const MAX_NESTING: usize = 4; |
| 150 | |
| 151 | fn 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. |
| 170 | const MAX_RULES: usize = 4; |
| 171 | const MAX_ZONE_RULES: usize = 50; |
| 172 | /// A rule with COUNT expands from DTSTART on every query. |
| 173 | const MAX_COUNT: u32 = 100_000; |
| 174 | const MAX_COUNT_SUB_DAILY: u32 = 10_000; |
| 175 | |
| 176 | fn 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. |
| 212 | pub 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. |
| 231 | pub 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 |