Skip to main content

netget/server/dav_common/
object.rs

1//! iCalendar (RFC 5545) and vCard (RFC 6350 / 2426) objects as CalDAV and CardDAV need them:
2//! content lines unfolded and parsed, components nested and checked, the UID found, and the
3//! properties the REPORT filters read.
4use anyhow::{bail, ensure, Context, Result};
5
6pub const MAX_OBJECT_BYTES: usize = 512 * 1024;
7const MAX_LINES: usize = 20_000;
8const MAX_DEPTH: usize = 8;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct Property {
12    pub name: String,
13    pub params: Vec<(String, String)>,
14    pub value: String,
15}
16
17impl Property {
18    pub fn param(&self, name: &str) -> Option<&str> {
19        self.params
20            .iter()
21            .find(|(k, _)| k.eq_ignore_ascii_case(name))
22            .map(|(_, v)| v.as_str())
23    }
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct Component {
28    pub name: String,
29    pub props: Vec<Property>,
30    pub children: Vec<Component>,
31}
32
33impl Component {
34    pub fn prop(&self, name: &str) -> Option<&Property> {
35        self.props
36            .iter()
37            .find(|p| p.name.eq_ignore_ascii_case(name))
38    }
39    pub fn props_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Property> + 'a {
40        self.props
41            .iter()
42            .filter(move |p| p.name.eq_ignore_ascii_case(name))
43    }
44}
45
46/// Unfold (CRLF or LF followed by a space or tab continues the line) and split.
47fn lines(text: &str) -> Result<Vec<String>> {
48    ensure!(
49        text.len() <= MAX_OBJECT_BYTES,
50        "object over {} KiB",
51        MAX_OBJECT_BYTES / 1024
52    );
53    let mut out: Vec<String> = Vec::new();
54    for raw in text.split('\n') {
55        let raw = raw.strip_suffix('\r').unwrap_or(raw);
56        if let Some(cont) = raw.strip_prefix([' ', '\t']) {
57            let last = out
58                .last_mut()
59                .context("object starts with a continuation line")?;
60            last.push_str(cont);
61        } else if !raw.is_empty() {
62            out.push(raw.to_owned());
63        }
64        ensure!(
65            out.len() <= MAX_LINES,
66            "object has more than {MAX_LINES} lines"
67        );
68    }
69    Ok(out)
70}
71
72fn name_ok(s: &str) -> bool {
73    !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
74}
75
76/// `name *(";" param) ":" value`, with quoted parameter values.
77fn parse_line(line: &str) -> Result<Property> {
78    let mut i = 0;
79    let b = line.as_bytes();
80    while i < b.len() && b[i] != b';' && b[i] != b':' {
81        i += 1;
82    }
83    let name = &line[..i];
84    // vCard 3 allows a group prefix: "item1.EMAIL".
85    let bare = name.rsplit('.').next().unwrap_or(name);
86    ensure!(
87        name_ok(bare) && name.split('.').all(name_ok),
88        "invalid property name {name:?}"
89    );
90    let mut params = Vec::new();
91    while i < b.len() && b[i] == b';' {
92        i += 1;
93        let start = i;
94        while i < b.len() && b[i] != b'=' && b[i] != b';' && b[i] != b':' {
95            i += 1;
96        }
97        let pname = line[start..i].to_owned();
98        ensure!(name_ok(&pname), "invalid parameter name in {name}");
99        let mut value = String::new();
100        if i < b.len() && b[i] == b'=' {
101            i += 1;
102            loop {
103                if i < b.len() && b[i] == b'"' {
104                    let end = line[i + 1..]
105                        .find('"')
106                        .context("unterminated quoted parameter")?
107                        + i
108                        + 1;
109                    value.push_str(&line[i + 1..end]);
110                    i = end + 1;
111                } else {
112                    let start = i;
113                    while i < b.len() && b[i] != b';' && b[i] != b':' && b[i] != b',' {
114                        i += 1;
115                    }
116                    value.push_str(&line[start..i]);
117                }
118                if i < b.len() && b[i] == b',' {
119                    value.push(',');
120                    i += 1;
121                } else {
122                    break;
123                }
124            }
125        }
126        params.push((pname, value));
127    }
128    ensure!(i < b.len() && b[i] == b':', "property {name} has no value");
129    Ok(Property {
130        name: bare.to_ascii_uppercase(),
131        params,
132        value: line[i + 1..].to_owned(),
133    })
134}
135
136/// Parse one top-level component (`BEGIN:<root>` ... `END:<root>`).
137pub fn parse(text: &str, root: &str) -> Result<Component> {
138    let lines = lines(text)?;
139    let mut stack: Vec<Component> = Vec::new();
140    let mut done: Option<Component> = None;
141    for line in &lines {
142        ensure!(done.is_none(), "content after END:{root}");
143        let p = parse_line(line)?;
144        match p.name.as_str() {
145            "BEGIN" => {
146                ensure!(stack.len() < MAX_DEPTH, "components nest too deeply");
147                ensure!(name_ok(&p.value), "invalid component name");
148                if stack.is_empty() {
149                    ensure!(p.value.eq_ignore_ascii_case(root), "expected BEGIN:{root}");
150                }
151                stack.push(Component {
152                    name: p.value.to_ascii_uppercase(),
153                    props: vec![],
154                    children: vec![],
155                });
156            }
157            "END" => {
158                let c = stack.pop().context("END without BEGIN")?;
159                ensure!(
160                    c.name.eq_ignore_ascii_case(&p.value),
161                    "END:{} closes {}",
162                    p.value,
163                    c.name
164                );
165                match stack.last_mut() {
166                    Some(parent) => parent.children.push(c),
167                    None => done = Some(c),
168                }
169            }
170            _ => stack
171                .last_mut()
172                .context("property outside a component")?
173                .props
174                .push(p),
175        }
176    }
177    ensure!(stack.is_empty(), "unterminated component");
178    done.with_context(|| format!("no {root} component"))
179}
180
181/// A calendar object resource (RFC 4791 §4.1): VCALENDAR with VERSION and PRODID, components of
182/// one type (timezones aside) sharing one UID. Returns (component type, UID).
183pub fn check_calendar(text: &str) -> Result<(String, String, Component)> {
184    let cal = parse(text, "VCALENDAR")?;
185    ensure!(
186        cal.prop("VERSION").is_some_and(|v| v.value == "2.0"),
187        "VCALENDAR needs VERSION:2.0"
188    );
189    ensure!(cal.prop("PRODID").is_some(), "VCALENDAR needs PRODID");
190    let items: Vec<&Component> = cal
191        .children
192        .iter()
193        .filter(|c| c.name != "VTIMEZONE")
194        .collect();
195    ensure!(!items.is_empty(), "no calendar component");
196    let kind = items[0].name.clone();
197    ensure!(
198        matches!(kind.as_str(), "VEVENT" | "VTODO" | "VJOURNAL" | "VFREEBUSY"),
199        "unsupported component {kind}"
200    );
201    ensure!(
202        items.iter().all(|c| c.name == kind),
203        "a calendar object holds one component type"
204    );
205    let uid = items[0]
206        .prop("UID")
207        .map(|u| u.value.clone())
208        .context("component without UID")?;
209    ensure!(
210        !uid.is_empty() && uid.len() <= 255,
211        "UID must be 1..255 characters"
212    );
213    ensure!(
214        items
215            .iter()
216            .all(|c| c.prop("UID").is_some_and(|u| u.value == uid)),
217        "all components share the UID"
218    );
219    Ok((kind, uid, cal))
220}
221
222/// A vCard address object (RFC 6352 §5.1): one VCARD with VERSION 3.0 or 4.0, FN and UID.
223pub fn check_vcard(text: &str) -> Result<(String, Component)> {
224    let card = parse(text, "VCARD")?;
225    ensure!(
226        card.prop("VERSION")
227            .is_some_and(|v| v.value == "3.0" || v.value == "4.0"),
228        "VCARD needs VERSION 3.0 or 4.0"
229    );
230    ensure!(card.prop("FN").is_some(), "VCARD needs FN");
231    let uid = card
232        .prop("UID")
233        .map(|u| u.value.clone())
234        .context("VCARD without UID")?;
235    ensure!(
236        !uid.is_empty() && uid.len() <= 255,
237        "UID must be 1..255 characters"
238    );
239    Ok((uid, card))
240}
241
242/// iCalendar DATE or DATE-TIME as seconds since the epoch (floating and TZID times read as UTC,
243/// which the CalDAV query documentation states).
244pub fn timestamp(value: &str) -> Result<i64> {
245    use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc};
246    let v = value.trim_end_matches('Z');
247    let t = if v.len() == 8 {
248        NaiveDate::parse_from_str(v, "%Y%m%d")?
249            .and_hms_opt(0, 0, 0)
250            .context("date")?
251    } else {
252        NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%S")?
253    };
254    Ok(Utc.from_utc_datetime(&t).timestamp())
255}
256
257/// RFC 5545 §3.3.6 duration in seconds (`P1D`, `PT1H30M`, `-P1W`).
258pub fn duration(value: &str) -> Result<i64> {
259    let (neg, v) = match value.strip_prefix('-') {
260        Some(r) => (true, r),
261        None => (false, value.strip_prefix('+').unwrap_or(value)),
262    };
263    let v = v.strip_prefix('P').context("duration starts with P")?;
264    let mut total = 0i64;
265    let mut num = String::new();
266    let mut time = false;
267    for c in v.chars() {
268        match c {
269            'T' => time = true,
270            '0'..='9' => num.push(c),
271            _ => {
272                let n: i64 = num.parse().context("duration number")?;
273                num.clear();
274                total += n * match (c, time) {
275                    ('W', _) => 604_800,
276                    ('D', _) => 86_400,
277                    ('H', true) => 3600,
278                    ('M', true) => 60,
279                    ('S', true) => 1,
280                    _ => bail!("invalid duration unit {c}"),
281                };
282            }
283        }
284    }
285    ensure!(num.is_empty(), "trailing number in duration");
286    Ok(if neg { -total } else { total })
287}
288
289/// Whether a component overlaps `[start, end)` (RFC 4791 §9.9). A recurring component (RRULE
290/// or RDATE) is treated as extending without end from its first start — recurrence sets are
291/// not expanded, so a query may return a recurring object whose instances miss the range, never
292/// the reverse.
293pub fn overlaps(c: &Component, start: Option<i64>, end: Option<i64>) -> bool {
294    let dt = |n: &str| c.prop(n).and_then(|p| timestamp(&p.value).ok());
295    let begin = match c.name.as_str() {
296        "VTODO" => dt("DTSTART").or_else(|| dt("DUE")),
297        _ => dt("DTSTART"),
298    };
299    let Some(b) = begin else {
300        return true;
301    };
302    let recurring = c.prop("RRULE").is_some() || c.prop("RDATE").is_some();
303    let finish = if recurring {
304        i64::MAX
305    } else if let Some(e) = dt("DTEND").or_else(|| dt("DUE")) {
306        e
307    } else if let Some(d) = c.prop("DURATION").and_then(|p| duration(&p.value).ok()) {
308        b + d
309    } else if c.prop("DTSTART").is_some_and(|p| p.value.len() == 8) {
310        b + 86_400
311    } else {
312        b
313    };
314    let after_start = start.is_none_or(|s| finish > s || (finish == b && b >= s));
315    let before_end = end.is_none_or(|e| b < e);
316    after_start && before_end
317}
318
319/// CalDAV/CardDAV text-match with the default `i;unicode-casemap` collation, approximated by
320/// Unicode lower-casing; `match_type` is equals, contains, starts-with or ends-with.
321pub fn text_match(value: &str, needle: &str, match_type: &str, negate: bool) -> bool {
322    let (v, n) = (value.to_lowercase(), needle.to_lowercase());
323    let hit = match match_type {
324        "equals" => v == n,
325        "starts-with" => v.starts_with(&n),
326        "ends-with" => v.ends_with(&n),
327        _ => v.contains(&n),
328    };
329    hit != negate
330}