object.rs
| 1 | //! Validation of the calendar and address objects clients PUT. |
| 2 | |
| 3 | use calcard::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. |
| 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 | let mut found: Option<CalendarObject> = None; |
| 62 | for c in root |
| 63 | .component_ids |
| 64 | .iter() |
| 65 | .filter_map(|&id| cal.components.get(id as usize)) |
| 66 | { |
| 67 | let component = match c.component_type { |
| 68 | ICalendarComponentType::VTimezone => continue, |
| 69 | ICalendarComponentType::VEvent => "VEVENT", |
| 70 | ICalendarComponentType::VTodo => "VTODO", |
| 71 | ICalendarComponentType::VJournal => "VJOURNAL", |
| 72 | _ => return Err(Invalid::CalendarComponent), |
| 73 | }; |
| 74 | let uid = c.uid().ok_or(Invalid::CalendarResource)?; |
| 75 | match &found { |
| 76 | Some(f) if f.uid != uid || f.component != component => { |
| 77 | return Err(Invalid::CalendarResource); |
| 78 | } |
| 79 | Some(_) => {} |
| 80 | None => { |
| 81 | found = Some(CalendarObject { |
| 82 | uid: uid.to_string(), |
| 83 | component, |
| 84 | }) |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | let found = found.ok_or(Invalid::CalendarResource)?; |
| 89 | if !supported.contains(&found.component) { |
| 90 | return Err(Invalid::CalendarComponent); |
| 91 | } |
| 92 | Ok(found) |
| 93 | } |
| 94 | |
| 95 | /// Checks an address object resource (RFC 6352, 5.1) and returns its UID. A |
| 96 | /// card without one is accepted: several clients omit it. |
| 97 | pub fn vcard(body: &[u8]) -> Result<Option<String>, Invalid> { |
| 98 | let text = std::str::from_utf8(body).map_err(|_| Invalid::AddressData)?; |
| 99 | let mut parser = Parser::new(text); |
| 100 | let Entry::VCard(card) = parser.entry() else { |
| 101 | return Err(Invalid::AddressData); |
| 102 | }; |
| 103 | if !matches!(parser.entry(), Entry::Eof) { |
| 104 | return Err(Invalid::AddressData); |
| 105 | } |
| 106 | Ok(card.uid().map(str::to_string)) |
| 107 | } |
| 108 | |
| 109 | /// `data` with `DTSTAMP:<now>` inserted after the BEGIN line of each VEVENT, |
| 110 | /// VTODO, VJOURNAL and VFREEBUSY that lacks it (RFC 5545 requires it). |
| 111 | /// Inserts text instead of re-serializing, so every other byte stays. |
| 112 | /// `None` if nothing was missing. |
| 113 | pub fn with_dtstamp(data: &[u8], now: DateTime<Utc>) -> Option<Vec<u8>> { |
| 114 | const STAMPED: [&[u8]; 4] = [b"VEVENT", b"VTODO", b"VJOURNAL", b"VFREEBUSY"]; |
| 115 | let lines: Vec<&[u8]> = data.split_inclusive(|&b| b == b'\n').collect(); |
| 116 | // (component, index of its BEGIN line, has DTSTAMP) |
| 117 | let mut open: Vec<(&[u8], usize, bool)> = Vec::new(); |
| 118 | let mut missing = Vec::new(); |
| 119 | for (i, line) in lines.iter().enumerate() { |
| 120 | if line.first().is_some_and(|b| *b == b' ' || *b == b'\t') { |
| 121 | continue; |
| 122 | } |
| 123 | let line = line.trim_ascii_end(); |
| 124 | let name_end = line |
| 125 | .iter() |
| 126 | .position(|b| *b == b':' || *b == b';') |
| 127 | .unwrap_or(line.len()); |
| 128 | let (name, value) = ( |
| 129 | &line[..name_end], |
| 130 | line.get(name_end + 1..).unwrap_or_default(), |
| 131 | ); |
| 132 | if name.eq_ignore_ascii_case(b"BEGIN") { |
| 133 | open.push((value, i, false)); |
| 134 | } else if name.eq_ignore_ascii_case(b"END") { |
| 135 | if let Some((comp, begin, false)) = open.pop() |
| 136 | && STAMPED.iter().any(|s| comp.eq_ignore_ascii_case(s)) |
| 137 | { |
| 138 | missing.push(begin); |
| 139 | } |
| 140 | } else if name.eq_ignore_ascii_case(b"DTSTAMP") |
| 141 | && let Some(top) = open.last_mut() |
| 142 | { |
| 143 | top.2 = true; |
| 144 | } |
| 145 | } |
| 146 | if missing.is_empty() { |
| 147 | return None; |
| 148 | } |
| 149 | let stamp = now.format("DTSTAMP:%Y%m%dT%H%M%SZ").to_string(); |
| 150 | let mut out = Vec::with_capacity(data.len() + missing.len() * 28); |
| 151 | for (i, line) in lines.iter().enumerate() { |
| 152 | out.extend_from_slice(line); |
| 153 | if missing.contains(&i) { |
| 154 | let lf_only = line.ends_with(b"\n") && !line.ends_with(b"\r\n"); |
| 155 | let eol: &[u8] = if lf_only { b"\n" } else { b"\r\n" }; |
| 156 | if !line.ends_with(b"\n") { |
| 157 | out.extend_from_slice(eol); |
| 158 | } |
| 159 | out.extend_from_slice(stamp.as_bytes()); |
| 160 | out.extend_from_slice(eol); |
| 161 | } |
| 162 | } |
| 163 | Some(out) |
| 164 | } |
| 165 |