use anyhow::{bail, ensure, Context, Result};
pub const MAX_OBJECT_BYTES: usize = 512 * 1024;
const MAX_LINES: usize = 20_000;
const MAX_DEPTH: usize = 8;
#[derive(Debug, Clone, PartialEq)]
pub struct Property {
pub name: String,
pub params: Vec<(String, String)>,
pub value: String,
}
impl Property {
pub fn param(&self, name: &str) -> Option<&str> {
self.params
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Component {
pub name: String,
pub props: Vec<Property>,
pub children: Vec<Component>,
}
impl Component {
pub fn prop(&self, name: &str) -> Option<&Property> {
self.props
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
}
pub fn props_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Property> + 'a {
self.props
.iter()
.filter(move |p| p.name.eq_ignore_ascii_case(name))
}
}
fn lines(text: &str) -> Result<Vec<String>> {
ensure!(
text.len() <= MAX_OBJECT_BYTES,
"object over {} KiB",
MAX_OBJECT_BYTES / 1024
);
let mut out: Vec<String> = Vec::new();
for raw in text.split('\n') {
let raw = raw.strip_suffix('\r').unwrap_or(raw);
if let Some(cont) = raw.strip_prefix([' ', '\t']) {
let last = out
.last_mut()
.context("object starts with a continuation line")?;
last.push_str(cont);
} else if !raw.is_empty() {
out.push(raw.to_owned());
}
ensure!(
out.len() <= MAX_LINES,
"object has more than {MAX_LINES} lines"
);
}
Ok(out)
}
fn name_ok(s: &str) -> bool {
!s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
fn parse_line(line: &str) -> Result<Property> {
let mut i = 0;
let b = line.as_bytes();
while i < b.len() && b[i] != b';' && b[i] != b':' {
i += 1;
}
let name = &line[..i];
let bare = name.rsplit('.').next().unwrap_or(name);
ensure!(
name_ok(bare) && name.split('.').all(name_ok),
"invalid property name {name:?}"
);
let mut params = Vec::new();
while i < b.len() && b[i] == b';' {
i += 1;
let start = i;
while i < b.len() && b[i] != b'=' && b[i] != b';' && b[i] != b':' {
i += 1;
}
let pname = line[start..i].to_owned();
ensure!(name_ok(&pname), "invalid parameter name in {name}");
let mut value = String::new();
if i < b.len() && b[i] == b'=' {
i += 1;
loop {
if i < b.len() && b[i] == b'"' {
let end = line[i + 1..]
.find('"')
.context("unterminated quoted parameter")?
+ i
+ 1;
value.push_str(&line[i + 1..end]);
i = end + 1;
} else {
let start = i;
while i < b.len() && b[i] != b';' && b[i] != b':' && b[i] != b',' {
i += 1;
}
value.push_str(&line[start..i]);
}
if i < b.len() && b[i] == b',' {
value.push(',');
i += 1;
} else {
break;
}
}
}
params.push((pname, value));
}
ensure!(i < b.len() && b[i] == b':', "property {name} has no value");
Ok(Property {
name: bare.to_ascii_uppercase(),
params,
value: line[i + 1..].to_owned(),
})
}
pub fn parse(text: &str, root: &str) -> Result<Component> {
let lines = lines(text)?;
let mut stack: Vec<Component> = Vec::new();
let mut done: Option<Component> = None;
for line in &lines {
ensure!(done.is_none(), "content after END:{root}");
let p = parse_line(line)?;
match p.name.as_str() {
"BEGIN" => {
ensure!(stack.len() < MAX_DEPTH, "components nest too deeply");
ensure!(name_ok(&p.value), "invalid component name");
if stack.is_empty() {
ensure!(p.value.eq_ignore_ascii_case(root), "expected BEGIN:{root}");
}
stack.push(Component {
name: p.value.to_ascii_uppercase(),
props: vec![],
children: vec![],
});
}
"END" => {
let c = stack.pop().context("END without BEGIN")?;
ensure!(
c.name.eq_ignore_ascii_case(&p.value),
"END:{} closes {}",
p.value,
c.name
);
match stack.last_mut() {
Some(parent) => parent.children.push(c),
None => done = Some(c),
}
}
_ => stack
.last_mut()
.context("property outside a component")?
.props
.push(p),
}
}
ensure!(stack.is_empty(), "unterminated component");
done.with_context(|| format!("no {root} component"))
}
pub fn check_calendar(text: &str) -> Result<(String, String, Component)> {
let cal = parse(text, "VCALENDAR")?;
ensure!(
cal.prop("VERSION").is_some_and(|v| v.value == "2.0"),
"VCALENDAR needs VERSION:2.0"
);
ensure!(cal.prop("PRODID").is_some(), "VCALENDAR needs PRODID");
let items: Vec<&Component> = cal
.children
.iter()
.filter(|c| c.name != "VTIMEZONE")
.collect();
ensure!(!items.is_empty(), "no calendar component");
let kind = items[0].name.clone();
ensure!(
matches!(kind.as_str(), "VEVENT" | "VTODO" | "VJOURNAL" | "VFREEBUSY"),
"unsupported component {kind}"
);
ensure!(
items.iter().all(|c| c.name == kind),
"a calendar object holds one component type"
);
let uid = items[0]
.prop("UID")
.map(|u| u.value.clone())
.context("component without UID")?;
ensure!(
!uid.is_empty() && uid.len() <= 255,
"UID must be 1..255 characters"
);
ensure!(
items
.iter()
.all(|c| c.prop("UID").is_some_and(|u| u.value == uid)),
"all components share the UID"
);
Ok((kind, uid, cal))
}
pub fn check_vcard(text: &str) -> Result<(String, Component)> {
let card = parse(text, "VCARD")?;
ensure!(
card.prop("VERSION")
.is_some_and(|v| v.value == "3.0" || v.value == "4.0"),
"VCARD needs VERSION 3.0 or 4.0"
);
ensure!(card.prop("FN").is_some(), "VCARD needs FN");
let uid = card
.prop("UID")
.map(|u| u.value.clone())
.context("VCARD without UID")?;
ensure!(
!uid.is_empty() && uid.len() <= 255,
"UID must be 1..255 characters"
);
Ok((uid, card))
}
pub fn timestamp(value: &str) -> Result<i64> {
use chrono::{NaiveDate, NaiveDateTime, TimeZone, Utc};
let v = value.trim_end_matches('Z');
let t = if v.len() == 8 {
NaiveDate::parse_from_str(v, "%Y%m%d")?
.and_hms_opt(0, 0, 0)
.context("date")?
} else {
NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%S")?
};
Ok(Utc.from_utc_datetime(&t).timestamp())
}
pub fn duration(value: &str) -> Result<i64> {
let (neg, v) = match value.strip_prefix('-') {
Some(r) => (true, r),
None => (false, value.strip_prefix('+').unwrap_or(value)),
};
let v = v.strip_prefix('P').context("duration starts with P")?;
let mut total = 0i64;
let mut num = String::new();
let mut time = false;
for c in v.chars() {
match c {
'T' => time = true,
'0'..='9' => num.push(c),
_ => {
let n: i64 = num.parse().context("duration number")?;
num.clear();
total += n * match (c, time) {
('W', _) => 604_800,
('D', _) => 86_400,
('H', true) => 3600,
('M', true) => 60,
('S', true) => 1,
_ => bail!("invalid duration unit {c}"),
};
}
}
}
ensure!(num.is_empty(), "trailing number in duration");
Ok(if neg { -total } else { total })
}
pub fn overlaps(c: &Component, start: Option<i64>, end: Option<i64>) -> bool {
let dt = |n: &str| c.prop(n).and_then(|p| timestamp(&p.value).ok());
let begin = match c.name.as_str() {
"VTODO" => dt("DTSTART").or_else(|| dt("DUE")),
_ => dt("DTSTART"),
};
let Some(b) = begin else {
return true;
};
let recurring = c.prop("RRULE").is_some() || c.prop("RDATE").is_some();
let finish = if recurring {
i64::MAX
} else if let Some(e) = dt("DTEND").or_else(|| dt("DUE")) {
e
} else if let Some(d) = c.prop("DURATION").and_then(|p| duration(&p.value).ok()) {
b + d
} else if c.prop("DTSTART").is_some_and(|p| p.value.len() == 8) {
b + 86_400
} else {
b
};
let after_start = start.is_none_or(|s| finish > s || (finish == b && b >= s));
let before_end = end.is_none_or(|e| b < e);
after_start && before_end
}
pub fn text_match(value: &str, needle: &str, match_type: &str, negate: bool) -> bool {
let (v, n) = (value.to_lowercase(), needle.to_lowercase());
let hit = match match_type {
"equals" => v == n,
"starts-with" => v.starts_with(&n),
"ends-with" => v.ends_with(&n),
_ => v.contains(&n),
};
hit != negate
}