//! XML request parsing, response building, and PUT body validation.
use pimdav::object::{self, CalendarObject, Invalid};
use pimdav::xml::{self, APPLE, CALDAV, DAV, Name, Propfind};
use xmltree::Element;
#[test]
fn propfind_bodies() {
assert_eq!(xml::propfind(b""), Ok(Propfind::AllProp(vec![])));
let body = br#"
"#;
assert_eq!(
xml::propfind(body),
Ok(Propfind::Prop(vec![
Name::new(DAV, "getetag"),
Name::new("urn:x", "odd")
]))
);
let body = br#""#;
assert_eq!(
xml::propfind(body),
Ok(Propfind::AllProp(vec![Name::new(DAV, "getetag")]))
);
let body = br#""#;
assert_eq!(xml::propfind(body), Ok(Propfind::PropName));
assert!(xml::propfind(b"").is_err());
assert!(xml::propfind(b"
Work#ff0000
"#;
// The later remove replaces the set of the same property.
let u = xml::update(body).unwrap();
assert_eq!(u.set.len(), 1);
assert!(Name::of(&u.set[0]).is(APPLE, "calendar-color"));
assert_eq!(u.remove, vec![Name::new(DAV, "displayname")]);
// A repeated set keeps only the last value.
let body = br#"
A
B
"#;
let u = xml::update(body).unwrap();
assert_eq!(u.set.len(), 1);
assert_eq!(xml::text(&u.set[0]), "B");
let body = br#"
"#;
let u = xml::update(body).unwrap();
assert!(Name::of(&u.set[0]).is(CALDAV, "supported-calendar-component-set"));
assert!(xml::update(b"").unwrap().set.is_empty());
assert!(xml::update(b"").is_err());
}
#[test]
fn multistatus_round_trips() {
let mut r = xml::Response::new("/a b/");
r.push(
200,
xml::with_text(xml::el(DAV, "displayname"), "x < y & \"z\""),
);
r.push(404, Name::new("urn:x", "odd").element());
r.push(
200,
xml::with_attr(xml::el(CALDAV, "comp"), "name", "VEVENT"),
);
let out = xml::multistatus(&Name::new(DAV, "multistatus"), &[r]);
let root = Element::parse(out.as_bytes()).unwrap();
assert!(Name::of(&root).is(DAV, "multistatus"));
let response = xml::child(&root, DAV, "response").unwrap();
assert_eq!(
xml::text(xml::child(response, DAV, "href").unwrap()),
"/a b/"
);
let stats: Vec<_> = xml::elements(response)
.filter(|e| e.name == "propstat")
.collect();
assert_eq!(stats.len(), 2);
let ok = xml::child(stats[0], DAV, "prop").unwrap();
assert_eq!(
xml::text(xml::child(ok, DAV, "displayname").unwrap()),
"x < y & \"z\""
);
assert_eq!(
xml::child(ok, CALDAV, "comp").unwrap().attributes["name"],
"VEVENT"
);
let missing = xml::child(stats[1], DAV, "prop").unwrap();
assert!(xml::child(missing, "urn:x", "odd").is_some());
assert_eq!(
xml::text(xml::child(stats[1], DAV, "status").unwrap()),
"HTTP/1.1 404 Not Found"
);
}
#[test]
fn deep_bodies_and_control_characters() {
let deep = format!(
"{}{}",
"".repeat(20_000),
"".repeat(20_000)
);
assert!(xml::propfind(deep.as_bytes()).is_err());
assert!(xml::update(deep.replace("propfind", "propertyupdate").as_bytes()).is_err());
let mut r = xml::Response::new("/a/");
r.push(200, xml::with_text(xml::el(DAV, "displayname"), "a\u{1}b"));
let out = xml::multistatus(&Name::new(DAV, "multistatus"), &[r]);
let root = Element::parse(out.as_bytes()).unwrap();
let response = xml::child(&root, DAV, "response").unwrap();
let prop = xml::child(xml::child(response, DAV, "propstat").unwrap(), DAV, "prop").unwrap();
assert_eq!(
xml::text(xml::child(prop, DAV, "displayname").unwrap()),
"a\u{fffd}b"
);
}
fn ics(body: &str) -> Vec {
format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{body}END:VCALENDAR\r\n")
.into_bytes()
}
const EVENT: &str = "BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nEND:VEVENT\r\n";
#[test]
fn calendar_objects() {
let all = ["VEVENT", "VTODO"];
assert_eq!(
object::calendar(&ics(EVENT), &all),
Ok(CalendarObject {
uid: "a".into(),
component: "VEVENT"
})
);
let override_ = EVENT.replace("DTSTART", "RECURRENCE-ID:20260102T100000Z\r\nDTSTART");
assert!(object::calendar(&ics(&format!("{EVENT}{override_}")), &all).is_ok());
let todo = "BEGIN:VTODO\r\nUID:t\r\nDTSTAMP:20260101T000000Z\r\nEND:VTODO\r\n";
assert!(object::calendar(&ics(todo), &all).is_ok());
let cases = [
(
ics(&format!("METHOD:REQUEST\r\n{EVENT}")),
Invalid::CalendarResource,
),
(
ics(&format!("{EVENT}{}", EVENT.replace("UID:a", "UID:b"))),
Invalid::CalendarResource,
),
(
ics(&format!("{EVENT}{}", EVENT.replace("VEVENT", "VTODO"))),
Invalid::CalendarResource,
),
(
ics(&EVENT.replace("UID:a\r\n", "")),
Invalid::CalendarResource,
),
(ics(""), Invalid::CalendarResource),
(
ics(&EVENT.replace("VEVENT", "VJOURNAL")),
Invalid::CalendarComponent,
),
(
ics(&EVENT.replace("DTSTART:20260101T100000Z\r\n", "")),
Invalid::CalendarData,
),
(
ics("BEGIN:VTODO\r\nUID:t\r\nDURATION:PT1H\r\nEND:VTODO\r\n"),
Invalid::CalendarData,
),
(b"not a calendar".to_vec(), Invalid::CalendarData),
(vec![0xff, 0xfe], Invalid::CalendarData),
([ics(EVENT), ics(EVENT)].concat(), Invalid::CalendarResource),
(
ics(&EVENT.replace(
"END:VEVENT",
&format!(
"{}{}END:VEVENT",
"BEGIN:VALARM\r\n".repeat(5),
"END:VALARM\r\n".repeat(5)
),
)),
Invalid::CalendarData,
),
];
for (body, want) in cases {
assert_eq!(
object::calendar(&body, &all),
Err(want),
"{}",
String::from_utf8_lossy(&body)
);
}
}
#[test]
fn vcards() {
let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:c1\r\nFN:Bob\r\nEND:VCARD\r\n";
assert_eq!(object::vcard(card.as_bytes()), Ok(Some("c1".into())));
let no_uid = card.replace("UID:c1\r\n", "");
assert_eq!(object::vcard(no_uid.as_bytes()), Ok(None));
assert_eq!(object::vcard(&ics(EVENT)), Err(Invalid::AddressData));
assert_eq!(
object::vcard(format!("{card}{card}").as_bytes()),
Err(Invalid::AddressData)
);
let cut = card.replace("END:VCARD\r\n", "");
assert_eq!(object::vcard(cut.as_bytes()), Err(Invalid::AddressData));
}
#[test]
fn missing_dtstamp_is_inserted() {
let now = chrono::DateTime::from_timestamp(1_790_000_000, 0).unwrap();
let sent = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a\r\nDESCRIPTION:long\r\n line\r\n\
BEGIN:VALARM\r\nDTSTAMP:20260101T000000Z\r\nEND:VALARM\r\nEND:VEVENT\r\n\
BEGIN:VTODO\r\nDTSTAMP:20260101T000000Z\r\nUID:b\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
let got = object::with_dtstamp(sent.as_bytes(), now).unwrap();
// The VALARM's DTSTAMP does not count for the VEVENT around it.
let want = sent.replacen(
"BEGIN:VEVENT\r\n",
"BEGIN:VEVENT\r\nDTSTAMP:20260921T141320Z\r\n",
1,
);
assert_eq!(String::from_utf8(got).unwrap(), want);
assert_eq!(object::with_dtstamp(want.as_bytes(), now), None);
let lf = "BEGIN:VCALENDAR\nBEGIN:VJOURNAL\nUID:c\nEND:VJOURNAL\nEND:VCALENDAR\n";
let got = object::with_dtstamp(lf.as_bytes(), now).unwrap();
assert_eq!(
String::from_utf8(got).unwrap(),
lf.replace("VJOURNAL\nUID", "VJOURNAL\nDTSTAMP:20260921T141320Z\nUID")
);
}
#[test]
fn too_many_rules_are_refused() {
let rules = |n: usize| "RRULE:FREQ=DAILY\r\n".repeat(n);
let event = |n| EVENT.replace("END:VEVENT", &format!("{}END:VEVENT", rules(n)));
assert!(object::calendar(&ics(&event(4)), &["VEVENT"]).is_ok());
assert_eq!(
object::calendar(&ics(&event(5)), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let zone = |n| {
format!(
"BEGIN:VTIMEZONE\r\nTZID:X\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n{}END:STANDARD\r\nEND:VTIMEZONE\r\n",
rules(n)
)
};
assert!(object::calendar(&ics(&format!("{}{EVENT}", zone(50))), &["VEVENT"]).is_ok());
assert_eq!(
object::calendar(&ics(&format!("{}{EVENT}", zone(51))), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
// The cap is per zone, not per object.
let zones = (0..3)
.map(|i| zone(50).replace("TZID:X", &format!("TZID:X{i}")))
.collect::();
assert!(object::calendar(&ics(&format!("{zones}{EVENT}")), &["VEVENT"]).is_ok());
// But zones and their rules are capped in total.
let zones = |n: usize, rules: usize| {
(0..n)
.map(|i| zone(rules).replace("TZID:X", &format!("TZID:X{i}")))
.collect::()
};
assert_eq!(
object::calendar(&ics(&format!("{}{EVENT}", zones(51, 0))), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
assert_eq!(
object::calendar(&ics(&format!("{}{EVENT}", zones(11, 50))), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
// An observance outside any zone counts too.
let stray = zone(501)
.replace("BEGIN:VTIMEZONE\r\nTZID:X\r\n", "")
.replace("END:VTIMEZONE\r\n", "");
assert_eq!(
object::calendar(&ics(&format!("{stray}{EVENT}")), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
}
#[test]
fn huge_objects_are_refused() {
let attendees = |n: usize| {
let lines: String = (0..n)
.map(|i| format!("ATTENDEE:mailto:u{i}@x\r\n"))
.collect();
EVENT.replace("END:VEVENT", &format!("{lines}END:VEVENT"))
};
assert!(object::calendar(&ics(&attendees(2000)), &["VEVENT"]).is_ok());
assert_eq!(
object::calendar(&ics(&attendees(2001)), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let alarm = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\n";
let alarms = EVENT.replace("END:VEVENT", &format!("{}END:VEVENT", alarm.repeat(4000)));
assert_eq!(
object::calendar(&ics(&alarms), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let card = |n: usize| {
let notes = "NOTE:x\r\n".repeat(n);
format!("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\n{notes}END:VCARD\r\n")
};
assert!(object::vcard(card(9000).as_bytes()).is_ok());
assert_eq!(
object::vcard(card(10_000).as_bytes()),
Err(Invalid::AddressData)
);
}
#[test]
fn a_rule_with_a_huge_count_is_refused() {
let with = |rule: &str| {
let event = EVENT.replace("END:VEVENT", &format!("{rule}\r\nEND:VEVENT"));
object::calendar(&ics(&event), &["VEVENT"])
};
assert!(with("RRULE:FREQ=DAILY;COUNT=100000").is_ok());
assert_eq!(
with("RRULE:FREQ=DAILY;COUNT=100001"),
Err(Invalid::CalendarResource)
);
assert!(with("RRULE:FREQ=MINUTELY;COUNT=10000").is_ok());
assert_eq!(
with("EXRULE:FREQ=SECONDLY;COUNT=10001"),
Err(Invalid::CalendarResource)
);
}
#[test]
fn one_master_per_object_and_no_blank_uid() {
let master2 = EVENT.replace("T100000Z\r\nEND", "T110000Z\r\nEND");
assert_eq!(
object::calendar(&ics(&format!("{EVENT}{master2}")), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let blank = EVENT.replace("UID:a", "UID: ");
assert_eq!(
object::calendar(&ics(&blank), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
let card = b"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nUID: \r\nEND:VCARD\r\n";
assert_eq!(object::vcard(card), Ok(None));
}
#[test]
fn a_cut_or_nested_object_is_refused() {
let full = ics(EVENT);
let cut = &full[..full.len() - "END:VCALENDAR\r\n".len()];
assert_eq!(
object::calendar(cut, &["VEVENT"]),
Err(Invalid::CalendarData)
);
// END:VCALENDAR would close the open VEVENT for the parser.
let open = EVENT.replace("END:VEVENT\r\n", "");
assert_eq!(
object::calendar(&ics(&open), &["VEVENT"]),
Err(Invalid::CalendarData)
);
let inner = EVENT.replace("UID:a", "UID:b");
let nested = EVENT.replace("END:VEVENT", &format!("{inner}END:VEVENT"));
assert_eq!(
object::calendar(&ics(&nested), &["VEVENT"]),
Err(Invalid::CalendarResource)
);
}