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