//! Validation of the calendar and address objects clients PUT. use std::collections::HashSet; use calcard::icalendar::{ ICalendar, ICalendarComponentType, ICalendarFrequency, ICalendarProperty, ICalendarValue, }; use calcard::{Entry, Parser}; use chrono::{DateTime, Utc}; use xmltree::Element; use crate::xml::{CALDAV, CARDDAV, el}; /// Why a PUT body is refused, as the precondition the RFCs name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Invalid { /// Not parseable as iCalendar, or missing a property RFC 5545 requires. CalendarData, /// Parseable, but not one CalDAV object: several UIDs, mixed component /// types, a METHOD, or no component at all. CalendarResource, /// A component type the collection does not take. CalendarComponent, /// Not parseable as one vCard. AddressData, } impl Invalid { pub fn condition(self) -> Element { match self { Invalid::CalendarData => el(CALDAV, "valid-calendar-data"), Invalid::CalendarResource => el(CALDAV, "valid-calendar-object-resource"), Invalid::CalendarComponent => el(CALDAV, "supported-calendar-component"), Invalid::AddressData => el(CARDDAV, "valid-address-data"), } } } /// What the store needs to know about a valid calendar object. #[derive(Debug, PartialEq, Eq)] pub struct CalendarObject { pub uid: String, /// `VEVENT`, `VTODO` or `VJOURNAL`. pub component: &'static str, } /// Checks a calendar object resource (RFC 4791, 4.1). `supported` lists the /// component types the collection takes. pub fn calendar(body: &[u8], supported: &[&str]) -> Result { let text = std::str::from_utf8(body).map_err(|_| Invalid::CalendarData)?; if !ends_with(text, "END:VCALENDAR") { return Err(Invalid::CalendarData); } let mut parser = Parser::new(text); let Entry::ICalendar(cal) = parser.entry() else { return Err(Invalid::CalendarData); }; if !matches!(parser.entry(), Entry::Eof) { return Err(Invalid::CalendarResource); } let root = cal.components.first().ok_or(Invalid::CalendarData)?; if root.component_type != ICalendarComponentType::VCalendar { return Err(Invalid::CalendarData); } if root.has_property(&ICalendarProperty::Method) { return Err(Invalid::CalendarResource); } if too_deep(&cal) { return Err(Invalid::CalendarData); } if too_many_rules(&cal) { return Err(Invalid::CalendarResource); } let scheduled = |t: &ICalendarComponentType| { matches!( t, ICalendarComponentType::VEvent | ICalendarComponentType::VTodo | ICalendarComponentType::VJournal ) }; let top = root .component_ids .iter() .filter_map(|&id| cal.components.get(id as usize)); // One nested inside another escapes the one-UID check below. let all = cal .components .iter() .filter(|c| scheduled(&c.component_type)); if all.count() != top.clone().filter(|c| scheduled(&c.component_type)).count() { return Err(Invalid::CalendarResource); } let mut found: Option = None; let mut masters = 0; for c in top { let component = match c.component_type { ICalendarComponentType::VTimezone => continue, ICalendarComponentType::VEvent => "VEVENT", ICalendarComponentType::VTodo => "VTODO", ICalendarComponentType::VJournal => "VJOURNAL", _ => return Err(Invalid::CalendarComponent), }; // RFC 5545 requires DTSTART on a VEVENT without METHOD, and on a // VTODO with DURATION. let needs_start = match component { "VEVENT" => true, "VTODO" => c.has_property(&ICalendarProperty::Duration), _ => false, }; if needs_start && !c.has_property(&ICalendarProperty::Dtstart) { return Err(Invalid::CalendarData); } let uid = c .uid() .filter(|u| !u.trim().is_empty()) .ok_or(Invalid::CalendarResource)?; if !c.has_property(&ICalendarProperty::RecurrenceId) { masters += 1; if masters > 1 { return Err(Invalid::CalendarResource); } } match &found { Some(f) if f.uid != uid || f.component != component => { return Err(Invalid::CalendarResource); } Some(_) => {} None => { found = Some(CalendarObject { uid: uid.to_string(), component, }) } } } let found = found.ok_or(Invalid::CalendarResource)?; if !supported.contains(&found.component) { return Err(Invalid::CalendarComponent); } Ok(found) } /// Levels below VCALENDAR, as in VEVENT > PARTICIPANT > VLOCATION, with /// room to spare. Scheduling and rendering recurse once per level. const MAX_NESTING: usize = 4; fn too_deep(cal: &ICalendar) -> bool { let mut stack = vec![(0, 0)]; while let Some((i, depth)) = stack.pop() { if depth > MAX_NESTING { return true; } let ids = cal.components.get(i).map_or(&[][..], |c| &c.component_ids); stack.extend( ids.iter() .map(|&id| id as usize) .filter(|&id| id > i) .map(|id| (id, depth + 1)), ); } false } /// A rule that never matches costs up to 25 ms per expansion. Exported /// VTIMEZONEs can hold dozens of observances. const MAX_RULES: usize = 4; const MAX_ZONE_RULES: usize = 50; /// A rule with COUNT expands from DTSTART on every query. const MAX_COUNT: u32 = 100_000; const MAX_COUNT_SUB_DAILY: u32 = 10_000; fn too_many_rules(cal: &ICalendar) -> bool { let mut zone_rules = 0; for c in &cal.components { let rules: Vec<_> = c .entries .iter() .filter(|e| matches!(e.name, ICalendarProperty::Rrule | ICalendarProperty::Exrule)) .collect(); let costly = rules.iter().any(|e| match e.values.first() { Some(ICalendarValue::RecurrenceRule(r)) => r.count.is_some_and(|n| { n > match r.freq { ICalendarFrequency::Secondly | ICalendarFrequency::Minutely | ICalendarFrequency::Hourly => MAX_COUNT_SUB_DAILY, _ => MAX_COUNT, } }), _ => false, }); if costly { return true; } let rules = rules.len(); match c.component_type { ICalendarComponentType::Standard | ICalendarComponentType::Daylight => { zone_rules += rules } _ if rules > MAX_RULES => return true, _ => {} } } zone_rules > MAX_ZONE_RULES } /// Checks an address object resource (RFC 6352, 5.1) and returns its UID. A /// card without one is accepted: several clients omit it. pub fn vcard(body: &[u8]) -> Result, Invalid> { let text = std::str::from_utf8(body).map_err(|_| Invalid::AddressData)?; if !ends_with(text, "END:VCARD") { return Err(Invalid::AddressData); } let mut parser = Parser::new(text); let Entry::VCard(card) = parser.entry() else { return Err(Invalid::AddressData); }; if !matches!(parser.entry(), Entry::Eof) { return Err(Invalid::AddressData); } Ok(card .uid() .filter(|u| !u.trim().is_empty()) .map(str::to_string)) } /// Whether the last line of `text` is `end`. The parser accepts a body cut /// off before its END, and the store serves the body as it came. fn ends_with(text: &str, end: &str) -> bool { text.lines() .rev() .find(|l| !l.trim().is_empty()) .is_some_and(|l| l.trim().eq_ignore_ascii_case(end)) } /// `data` with `DTSTAMP:` inserted after the BEGIN line of each VEVENT, /// VTODO, VJOURNAL and VFREEBUSY that lacks it (RFC 5545 requires it). /// Inserts text instead of re-serializing, so every other byte stays. /// `None` if nothing was missing. pub fn with_dtstamp(data: &[u8], now: DateTime) -> Option> { const STAMPED: [&[u8]; 4] = [b"VEVENT", b"VTODO", b"VJOURNAL", b"VFREEBUSY"]; let lines: Vec<&[u8]> = data.split_inclusive(|&b| b == b'\n').collect(); // (component, index of its BEGIN line, has DTSTAMP) let mut open: Vec<(&[u8], usize, bool)> = Vec::new(); let mut missing = HashSet::new(); for (i, line) in lines.iter().enumerate() { if line.first().is_some_and(|b| *b == b' ' || *b == b'\t') { continue; } let line = line.trim_ascii_end(); let name_end = line .iter() .position(|b| *b == b':' || *b == b';') .unwrap_or(line.len()); let (name, value) = ( &line[..name_end], line.get(name_end + 1..).unwrap_or_default(), ); if name.eq_ignore_ascii_case(b"BEGIN") { open.push((value, i, false)); } else if name.eq_ignore_ascii_case(b"END") { if let Some((comp, begin, false)) = open.pop() && STAMPED.iter().any(|s| comp.eq_ignore_ascii_case(s)) { missing.insert(begin); } } else if name.eq_ignore_ascii_case(b"DTSTAMP") && let Some(top) = open.last_mut() { top.2 = true; } } if missing.is_empty() { return None; } let stamp = now.format("DTSTAMP:%Y%m%dT%H%M%SZ").to_string(); let mut out = Vec::with_capacity(data.len() + missing.len() * 28); for (i, line) in lines.iter().enumerate() { out.extend_from_slice(line); if missing.contains(&i) { let lf_only = line.ends_with(b"\n") && !line.ends_with(b"\r\n"); let eol: &[u8] = if lf_only { b"\n" } else { b"\r\n" }; if !line.ends_with(b"\n") { out.extend_from_slice(eol); } out.extend_from_slice(stamp.as_bytes()); out.extend_from_slice(eol); } } Some(out) }