fix(dav): parse multistatus with namespaces, not substrings #430

Merged
KofTwentyTwo merged 1 commits from fix/dav-namespace-xml into dev 2026-08-15 02:46:11 +00:00
11 changed files with 1442 additions and 57 deletions

No files matched your search

Generated
+1 -2
View File
@@ -2499,7 +2499,6 @@ dependencies = [
"chrono-tz 0.10.4",
"icalendar",
"nuncio-core",
"quick-xml",
"reqwest",
"rrule",
"serde",
@@ -2544,7 +2543,6 @@ dependencies = [
"async-trait",
"chrono",
"nuncio-core",
"quick-xml",
"regex",
"reqwest",
"serde",
@@ -2569,6 +2567,7 @@ dependencies = [
"futures",
"hex",
"hmac",
"quick-xml",
"regex",
"reqwest",
"serde",
-1
View File
@@ -21,7 +21,6 @@ chrono-tz.workspace = true
rrule.workspace = true
chrono.workspace = true
reqwest.workspace = true
quick-xml.workspace = true
[dev-dependencies]
tempfile.workspace = true
+159 -33
View File
@@ -1,6 +1,7 @@
//! CalDAV (RFC 4791) / WebDAV REPORT client and XML query generator.
use async_trait::async_trait;
use nuncio_core::dav::{parse_multistatus, CALDAV_NS};
use nuncio_core::model::CalendarEvent;
use nuncio_core::redact::Redacted;
use serde::{Deserialize, Serialize};
@@ -85,60 +86,74 @@ impl CalDavClient {
)
}
/// Parse CalDAV WebDAV XML `<multistatus>` response containing embedded VEVENT data.
/// Parse a CalDAV WebDAV XML `<multistatus>` response containing embedded VEVENT data.
///
/// The document is traversed with a namespace-aware parser and `calendar-data` is matched
/// on `{urn:ietf:params:xml:ns:caldav}calendar-data`, so the server's choice of prefix (or
/// of a default namespace) is irrelevant and a body this client cannot read is an error
/// rather than an empty -- and indistinguishable from genuinely empty -- event list.
///
/// A VEVENT block that fails to parse is dropped from the returned events but never
/// silently: it is logged at `warn` (with its UID, if extractable, and a short parse-error
/// reason -- never the raw VEVENT body) so a sync that quietly lost calendar data is
/// visible in telemetry instead of just showing up as a smaller-than-expected event count.
/// A `<response>` that carried no readable `calendar-data` at all (a `404` propstat, say)
/// is logged the same way.
pub fn parse_multistatus_response(
&self,
calendar_id: &str,
raw_xml: &str,
) -> Result<Vec<CalendarEvent>, CalendarError> {
let (events, _dropped) = self.parse_multistatus_response_counted(calendar_id, raw_xml);
Ok(events)
self.parse_multistatus_response_counted(calendar_id, raw_xml)
.map(|(events, _dropped)| events)
}
/// Same parse as [`Self::parse_multistatus_response`], additionally reporting how many
/// VEVENT blocks were dropped as unparseable so callers can log round-trip counts.
/// calendar entries were lost -- unparseable VEVENTs plus unreadable responses -- so
/// callers can log round-trip counts.
fn parse_multistatus_response_counted(
&self,
calendar_id: &str,
raw_xml: &str,
) -> (Vec<CalendarEvent>, usize) {
let mut events = Vec::new();
let mut dropped = 0usize;
) -> Result<(Vec<CalendarEvent>, usize), CalendarError> {
let report = parse_multistatus(raw_xml, CALDAV_NS, "calendar-data")
.map_err(|e| CalendarError::MalformedResponse(e.to_string()))?;
// Extract <c:calendar-data> or <calendar-data> text blocks
for block in raw_xml.split("<c:calendar-data>") {
if let Some((ics_data, _)) = block.split_once("</c:calendar-data>") {
let clean_ics = ics_data.trim();
if !clean_ics.is_empty() {
let event_id = format!("caldav-evt-{}", events.len() + dropped + 1);
match IcalParserAdapter::parse_ical(
&event_id,
&self.config.account_id,
calendar_id,
clean_ics,
) {
Ok(event) => events.push(event),
Err(err) => {
dropped += 1;
warn!(
account_id = %self.config.account_id,
calendar_id = %calendar_id,
uid = %extract_vevent_uid(clean_ics).unwrap_or_else(|| "unknown".to_string()),
reason = %err,
"dropped unparseable VEVENT"
);
}
}
let mut dropped = report.skipped.len();
for skipped in &report.skipped {
warn!(
account_id = %self.config.account_id,
calendar_id = %calendar_id,
href = %skipped.href.as_deref().unwrap_or("unknown"),
reason = %skipped.reason,
"dropped CalDAV response with no readable calendar-data"
);
}
let mut events = Vec::new();
for (index, entry) in report.values.iter().enumerate() {
let event_id = format!("caldav-evt-{}", index + 1);
match IcalParserAdapter::parse_ical(
&event_id,
&self.config.account_id,
calendar_id,
&entry.value,
) {
Ok(event) => events.push(event),
Err(err) => {
dropped += 1;
warn!(
account_id = %self.config.account_id,
calendar_id = %calendar_id,
uid = %extract_vevent_uid(&entry.value).unwrap_or_else(|| "unknown".to_string()),
reason = %err,
"dropped unparseable VEVENT"
);
}
}
}
(events, dropped)
Ok((events, dropped))
}
/// Format a unix timestamp as the `YYYYMMDDTHHMMSSZ` form RFC 4791's
@@ -214,7 +229,7 @@ impl CalDavClient {
.await
.map_err(|e| CalendarError::TransportFailed(e.to_string()))?;
let (events, dropped) = self.parse_multistatus_response_counted(calendar_id, &raw_xml);
let (events, dropped) = self.parse_multistatus_response_counted(calendar_id, &raw_xml)?;
info!(
account_id = %self.config.account_id,
calendar_id = %calendar_id,
@@ -356,6 +371,117 @@ END:VCALENDAR</c:calendar-data>
}
}
/// The same document, written the three ways a compliant server may write it. Before
/// namespace-aware parsing, only the middle one produced any events at all.
#[test]
fn parse_multistatus_response_reads_any_prefix_binding_of_the_caldav_namespace() {
let client = CalDavClient::new(test_config());
let event_ics = "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nSUMMARY:Product Planning\nEND:VEVENT\nEND:VCALENDAR";
let uppercase = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav">
<D:response><D:href>/evt1.ics</D:href><D:propstat><D:prop>
<CAL:calendar-data>{event_ics}</CAL:calendar-data>
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
</D:multistatus>"#
);
let default_ns = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<response><href>/evt1.ics</href><propstat><prop>
<c:calendar-data>{event_ics}</c:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat></response>
</multistatus>"#
);
let odd_prefix = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:caldav">
<ns0:response><ns0:href>/evt1.ics</ns0:href><ns0:propstat><ns0:prop>
<ns1:calendar-data>{event_ics}</ns1:calendar-data>
</ns0:prop><ns0:status>HTTP/1.1 200 OK</ns0:status></ns0:propstat></ns0:response>
</ns0:multistatus>"#
);
for (label, xml) in [
("uppercase prefixes", &uppercase),
("default namespace", &default_ns),
("generated prefixes", &odd_prefix),
] {
let events = client
.parse_multistatus_response("cal-work", xml)
.unwrap_or_else(|e| panic!("{label} parses: {e}"));
assert_eq!(events.len(), 1, "{label}");
assert_eq!(events[0].summary, "Product Planning", "{label}");
}
}
#[test]
fn parse_multistatus_response_warns_when_a_response_has_no_readable_calendar_data() {
use crate::test_tracing::with_recorder;
let client = CalDavClient::new(test_config());
// The first response's property merely *contains* the searched name; the second
// returns the real property under a 404 propstat, which is not a value.
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/lookalike.ics</d:href>
<d:propstat><d:prop>
<c:calendar-data-summary>NOT THE PAYLOAD</c:calendar-data-summary>
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
<d:response>
<d:href>/gone.ics</d:href>
<d:propstat><d:prop>
<c:calendar-data>BEGIN:VCALENDAR
END:VCALENDAR</c:calendar-data>
</d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>
</d:response>
</d:multistatus>"#;
let (recorder, result) =
with_recorder(|| client.parse_multistatus_response("cal-work", xml_response));
let events = result.expect("an unreadable response is not fatal");
assert!(events.is_empty(), "neither response carries calendar-data");
let warnings: Vec<_> = recorder
.events()
.into_iter()
.filter(|e| e.level == tracing::Level::WARN)
.collect();
assert_eq!(warnings.len(), 2, "both omissions must be visible");
let hrefs: Vec<_> = warnings
.iter()
.filter_map(|w| w.fields.get("href").cloned())
.collect();
assert!(hrefs.contains(&"/lookalike.ics".to_string()));
assert!(hrefs.contains(&"/gone.ics".to_string()));
assert!(warnings
.iter()
.any(|w| w.fields.get("reason").is_some_and(|r| r.contains("404"))));
}
#[test]
fn parse_multistatus_response_rejects_a_body_it_cannot_read_instead_of_returning_empty() {
let client = CalDavClient::new(test_config());
// A malformed document: the `<d:response>` element is never closed.
let truncated = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"><d:response><d:href>/a.ics</d:href>"#;
assert!(matches!(
client.parse_multistatus_response("cal-work", truncated),
Err(CalendarError::MalformedResponse(_))
));
// A body that is not a multistatus at all (an intercepting proxy's error page).
let not_dav = "<html><body>502 Bad Gateway</body></html>";
assert!(matches!(
client.parse_multistatus_response("cal-work", not_dav),
Err(CalendarError::MalformedResponse(_))
));
}
#[test]
fn format_query_timestamp_produces_ical_utc_form() {
let formatted =
+6
View File
@@ -22,6 +22,12 @@ pub enum CalendarError {
#[error("unresolvable TZID timezone reference: {0}")]
UnresolvableTimezone(String),
/// The server answered, but its body is not a WebDAV `multistatus` document this client
/// can traverse. Distinct from an empty result: a response that cannot be read must never
/// be reported as a calendar with no events in it.
#[error("malformed CalDAV multistatus response: {0}")]
MalformedResponse(String),
/// CalDAV network/transport-layer failure (connection, TLS, or an unexpected HTTP status)
/// distinct from a payload that connected fine but failed to parse.
#[error("CalDAV transport failure: {0}")]
@@ -3,6 +3,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_cal::{CalDavAccountConfig, CalDavClient, CalendarBackend, CalendarError};
use nuncio_core::model::CalendarEvent;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -117,3 +118,184 @@ async fn wiremock_caldav_report_server_error_surfaces_as_transport_failure() {
assert!(matches!(err, CalendarError::TransportFailed(_)));
}
/// The same two events as [`multistatus_body`], serialized the way a server that binds the DAV
/// and CalDAV namespaces to different prefixes would write them. A substring-matching parser
/// reads zero events out of this and calls it success; a namespace-aware one reads two.
fn multistatus_body_with_uppercase_prefixes() -> String {
multistatus_body()
.replace("<d:", "<D:")
.replace("</d:", "</D:")
.replace(r#"xmlns:d="DAV:""#, r#"xmlns:D="DAV:""#)
.replace("<c:", "<CAL:")
.replace("</c:", "</CAL:")
.replace(
r#"xmlns:c="urn:ietf:params:xml:ns:caldav""#,
r#"xmlns:CAL="urn:ietf:params:xml:ns:caldav""#,
)
}
/// The same two events again, this time with `DAV:` as the document's default namespace, so no
/// DAV element carries a prefix at all.
fn multistatus_body_with_default_namespace() -> String {
multistatus_body()
.replace("<d:", "<")
.replace("</d:", "</")
.replace(r#"xmlns:d="DAV:""#, r#"xmlns="DAV:""#)
}
/// Serve `body` from a mock CalDAV endpoint and run one real `REPORT` round trip against it.
async fn fetch_against_body(
body: String,
account_id: &str,
) -> Result<Vec<CalendarEvent>, CalendarError> {
let mock_server = MockServer::start().await;
Mock::given(method("REPORT"))
.and(path("/calendars/work/"))
.respond_with(ResponseTemplate::new(207).set_body_string(body))
.mount(&mock_server)
.await;
let config = CalDavAccountConfig {
account_id: account_id.to_string(),
caldav_url: format!("{}/calendars/work/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string().into(),
};
CalDavClient::new(config)
.fetch_remote_events("cal-work", 1_700_000_000, 1_800_000_000)
.await
}
#[tokio::test]
async fn wiremock_caldav_report_reads_the_same_document_under_any_namespace_prefix() {
for (label, body) in [
(
"uppercase prefixes",
multistatus_body_with_uppercase_prefixes(),
),
(
"default namespace",
multistatus_body_with_default_namespace(),
),
] {
let events = fetch_against_body(body, "acct-wm-ns")
.await
.unwrap_or_else(|e| panic!("{label} fetches: {e}"));
assert_eq!(events.len(), 2, "{label}");
assert!(
events
.iter()
.any(|e| e.summary == "Wiremock CalDAV Standup"),
"{label}"
);
}
}
#[tokio::test]
async fn wiremock_caldav_report_ignores_a_property_that_merely_contains_the_searched_name() {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/calendars/work/lookalike.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data-summary>BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Decoy
END:VEVENT
END:VCALENDAR</c:calendar-data-summary>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/calendars/work/real.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Wiremock UTC Review
DTSTART:20240301T120000Z
DTEND:20240301T130000Z
END:VEVENT
END:VCALENDAR</c:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let events = fetch_against_body(body.to_string(), "acct-wm-lookalike")
.await
.expect("fetch succeeds");
assert_eq!(events.len(), 1, "the decoy element is not calendar-data");
assert_eq!(events[0].summary, "Wiremock UTC Review");
}
#[tokio::test]
async fn wiremock_caldav_report_treats_a_404_propstat_as_no_value() {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/calendars/work/gone.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Should Not Appear
DTSTART:20240301T120000Z
DTEND:20240301T130000Z
END:VEVENT
END:VCALENDAR</c:calendar-data>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/calendars/work/real.ics</d:href>
<d:propstat>
<d:prop>
<c:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Wiremock UTC Review
DTSTART:20240301T120000Z
DTEND:20240301T130000Z
END:VEVENT
END:VCALENDAR</c:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let events = fetch_against_body(body.to_string(), "acct-wm-404")
.await
.expect("fetch succeeds");
assert_eq!(events.len(), 1, "a 404 propstat carries no value");
assert_eq!(events[0].summary, "Wiremock UTC Review");
}
#[tokio::test]
async fn wiremock_caldav_report_malformed_body_surfaces_as_an_error_not_an_empty_calendar() {
// Well-formed HTTP, unreadable body: the `<d:response>` element is never closed.
let truncated = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"><d:response><d:href>/calendars/work/a.ics</d:href>"#;
let err = fetch_against_body(truncated.to_string(), "acct-wm-malformed")
.await
.expect_err("a body this client cannot read must not look like an empty calendar");
assert!(matches!(err, CalendarError::MalformedResponse(_)));
// A 200 response that is not a multistatus at all (an intercepting captive portal, say).
let not_dav = "<html><body>Sign in to continue</body></html>";
let err = fetch_against_body(not_dav.to_string(), "acct-wm-html")
.await
.expect_err("a non-multistatus body must not look like an empty calendar");
assert!(matches!(err, CalendarError::MalformedResponse(_)));
}
-1
View File
@@ -19,7 +19,6 @@ tracing = { workspace = true }
sqlx = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
reqwest = { workspace = true }
quick-xml = { workspace = true }
regex = { workspace = true }
async-trait = { workspace = true }
+167 -19
View File
@@ -1,10 +1,11 @@
//! CardDAV (RFC 6352) `REPORT` client and multistatus response parser.
use async_trait::async_trait;
use nuncio_core::dav::{parse_multistatus, CARDDAV_NS};
use nuncio_core::redact::Redacted;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{debug, info, instrument};
use tracing::{debug, info, instrument, warn};
use crate::backend::ContactsBackend;
use crate::models::Contact;
@@ -17,6 +18,12 @@ pub enum CardDavError {
#[error("failed to parse vCard payload: {0}")]
ParseFailed(String),
/// The server answered, but its body is not a WebDAV `multistatus` document this client
/// can traverse. Distinct from an empty result: a response that cannot be read must never
/// be reported as an address book with no contacts in it.
#[error("malformed CardDAV multistatus response: {0}")]
MalformedResponse(String),
/// CardDAV network/transport-layer failure (connection, TLS, or an unexpected HTTP
/// status) distinct from a payload that connected fine but failed to parse.
#[error("CardDAV transport failure: {0}")]
@@ -76,26 +83,56 @@ impl CardDavClient {
}
/// Parse a CardDAV WebDAV XML `<multistatus>` response containing embedded vCard data.
///
/// The document is traversed with a namespace-aware parser and `address-data` is matched on
/// `{urn:ietf:params:xml:ns:carddav}address-data`, so the server's choice of prefix (or of a
/// default namespace) is irrelevant and a body this client cannot read is an error rather
/// than an empty -- and indistinguishable from genuinely empty -- contact list.
pub fn parse_multistatus_response(
&self,
account_id: &str,
raw_xml: &str,
) -> Result<Vec<Contact>, CardDavError> {
let mut contacts = Vec::new();
self.parse_multistatus_response_counted(account_id, raw_xml)
.map(|(contacts, _dropped)| contacts)
}
for block in raw_xml.split("<card:address-data>") {
if let Some((vcard_data, _)) = block.split_once("</card:address-data>") {
let clean_vcard = vcard_data.trim();
if !clean_vcard.is_empty() {
let contact_id = format!("carddav-{account_id}-{}", contacts.len() + 1);
let contact =
VCardParserAdapter::parse_vcard(&contact_id, account_id, clean_vcard)?;
contacts.push(contact);
}
}
/// Same parse as [`Self::parse_multistatus_response`], additionally reporting how many
/// `<response>` elements carried no readable `address-data` so callers can log round-trip
/// counts. Each one is logged at `warn` as it is skipped -- an address book that came back
/// short must say so rather than look like an address book that is short.
///
/// An unparseable vCard is still fatal (unlike the CalDAV client, which drops individual
/// bad VEVENTs): a card that the server returned and this client could not read is an
/// error, not a contact to omit.
fn parse_multistatus_response_counted(
&self,
account_id: &str,
raw_xml: &str,
) -> Result<(Vec<Contact>, usize), CardDavError> {
let report = parse_multistatus(raw_xml, CARDDAV_NS, "address-data")
.map_err(|e| CardDavError::MalformedResponse(e.to_string()))?;
for skipped in &report.skipped {
warn!(
account_id = %account_id,
href = %skipped.href.as_deref().unwrap_or("unknown"),
reason = %skipped.reason,
"dropped CardDAV response with no readable address-data"
);
}
Ok(contacts)
let mut contacts = Vec::new();
for (index, entry) in report.values.iter().enumerate() {
let contact_id = format!("carddav-{account_id}-{}", index + 1);
contacts.push(VCardParserAdapter::parse_vcard(
&contact_id,
account_id,
&entry.value,
)?);
}
Ok((contacts, report.skipped.len()))
}
/// Issue a live CardDAV `REPORT` addressbook-query (RFC 6352 Section 8.6) against
@@ -146,15 +183,15 @@ impl CardDavClient {
.await
.map_err(|e| CardDavError::TransportFailed(e.to_string()))?;
// `parse_multistatus_response` fails fast on the first unparseable vCard (unlike the
// CalDAV client, which drops individual bad VEVENTs), so a successful return here means
// every card that was fetched parsed cleanly -- `dropped` is always 0 on this path.
let contacts = self.parse_multistatus_response(account_id, &raw_xml)?;
// The parse fails fast on the first unparseable vCard, so a successful return here means
// every card that was fetched parsed cleanly; `dropped` therefore counts only the
// responses the server returned without readable `address-data`.
let (contacts, dropped) = self.parse_multistatus_response_counted(account_id, &raw_xml)?;
info!(
account_id = %account_id,
fetched = contacts.len(),
fetched = contacts.len() + dropped,
parsed = contacts.len(),
dropped = 0,
dropped,
"completed CardDAV sync"
);
@@ -230,6 +267,117 @@ END:VCARD</card:address-data>
assert!(contacts.is_empty());
}
/// The same document, written the three ways a compliant server may write it. Before
/// namespace-aware parsing, only the middle one produced any contacts at all.
#[test]
fn parse_multistatus_response_reads_any_prefix_binding_of_the_carddav_namespace() {
let client = CardDavClient::new(test_config());
let card = "BEGIN:VCARD\nVERSION:4.0\nFN:Alice Dev\nEMAIL:alice@nuncio.mx\nEND:VCARD";
let uppercase = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<D:response><D:href>/alice.vcf</D:href><D:propstat><D:prop>
<CARD:address-data>{card}</CARD:address-data>
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
</D:multistatus>"#
);
let default_ns = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<response><href>/alice.vcf</href><propstat><prop>
<card:address-data>{card}</card:address-data>
</prop><status>HTTP/1.1 200 OK</status></propstat></response>
</multistatus>"#
);
let odd_prefix = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:carddav">
<ns0:response><ns0:href>/alice.vcf</ns0:href><ns0:propstat><ns0:prop>
<ns1:address-data>{card}</ns1:address-data>
</ns0:prop><ns0:status>HTTP/1.1 200 OK</ns0:status></ns0:propstat></ns0:response>
</ns0:multistatus>"#
);
for (label, xml) in [
("uppercase prefixes", &uppercase),
("default namespace", &default_ns),
("generated prefixes", &odd_prefix),
] {
let contacts = client
.parse_multistatus_response("acct-1", xml)
.unwrap_or_else(|e| panic!("{label} parses: {e}"));
assert_eq!(contacts.len(), 1, "{label}");
assert_eq!(contacts[0].display_name, "Alice Dev", "{label}");
}
}
#[test]
fn parse_multistatus_response_warns_when_a_response_has_no_readable_address_data() {
use crate::test_tracing::with_recorder;
let client = CardDavClient::new(test_config());
// The first response's property merely *contains* the searched name; the second
// returns the real property under a 404 propstat, which is not a value.
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/lookalike.vcf</d:href>
<d:propstat><d:prop>
<card:address-data-summary>NOT THE PAYLOAD</card:address-data-summary>
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
<d:response>
<d:href>/gone.vcf</d:href>
<d:propstat><d:prop>
<card:address-data>BEGIN:VCARD
END:VCARD</card:address-data>
</d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>
</d:response>
</d:multistatus>"#;
let (recorder, result) =
with_recorder(|| client.parse_multistatus_response("acct-1", xml_response));
let contacts = result.expect("an unreadable response is not fatal");
assert!(contacts.is_empty(), "neither response carries address-data");
let warnings: Vec<_> = recorder
.events()
.into_iter()
.filter(|e| e.level == tracing::Level::WARN)
.collect();
assert_eq!(warnings.len(), 2, "both omissions must be visible");
let hrefs: Vec<_> = warnings
.iter()
.filter_map(|w| w.fields.get("href").cloned())
.collect();
assert!(hrefs.contains(&"/lookalike.vcf".to_string()));
assert!(hrefs.contains(&"/gone.vcf".to_string()));
assert!(warnings
.iter()
.any(|w| w.fields.get("reason").is_some_and(|r| r.contains("404"))));
}
#[test]
fn parse_multistatus_response_rejects_a_body_it_cannot_read_instead_of_returning_empty() {
let client = CardDavClient::new(test_config());
// A malformed document: the `<d:response>` element is never closed.
let truncated = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"><d:response><d:href>/a.vcf</d:href>"#;
assert!(matches!(
client.parse_multistatus_response("acct-1", truncated),
Err(CardDavError::MalformedResponse(_))
));
// A body that is not a multistatus at all (an intercepting proxy's error page).
let not_dav = "<html><body>502 Bad Gateway</body></html>";
assert!(matches!(
client.parse_multistatus_response("acct-1", not_dav),
Err(CardDavError::MalformedResponse(_))
));
}
#[test]
fn debug_impl_redacts_auth_token() {
let config = test_config();
@@ -2,7 +2,9 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_contacts::{CardDavAccountConfig, CardDavClient, CardDavError, ContactsBackend};
use nuncio_contacts::{
CardDavAccountConfig, CardDavClient, CardDavError, Contact, ContactsBackend,
};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -154,3 +156,168 @@ END:VCARD</card:address-data>
assert!(matches!(err, CardDavError::ParseFailed(_)));
}
/// The same two cards as [`multistatus_body`], serialized the way a server that binds the DAV
/// and CardDAV namespaces to different prefixes would write them. A substring-matching parser
/// reads zero contacts out of this and calls it success; a namespace-aware one reads two.
fn multistatus_body_with_uppercase_prefixes() -> String {
multistatus_body()
.replace("<d:", "<D:")
.replace("</d:", "</D:")
.replace(r#"xmlns:d="DAV:""#, r#"xmlns:D="DAV:""#)
.replace("<card:", "<CARD:")
.replace("</card:", "</CARD:")
.replace(
r#"xmlns:card="urn:ietf:params:xml:ns:carddav""#,
r#"xmlns:CARD="urn:ietf:params:xml:ns:carddav""#,
)
}
/// The same two cards again, this time with `DAV:` as the document's default namespace, so no
/// DAV element carries a prefix at all.
fn multistatus_body_with_default_namespace() -> String {
multistatus_body()
.replace("<d:", "<")
.replace("</d:", "</")
.replace(r#"xmlns:d="DAV:""#, r#"xmlns="DAV:""#)
}
/// Serve `body` from a mock CardDAV endpoint and run one real `REPORT` round trip against it.
async fn fetch_against_body(body: String, account_id: &str) -> Result<Vec<Contact>, CardDavError> {
let mock_server = MockServer::start().await;
Mock::given(method("REPORT"))
.and(path("/addressbooks/contacts/"))
.respond_with(ResponseTemplate::new(207).set_body_string(body))
.mount(&mock_server)
.await;
let config = CardDavAccountConfig {
account_id: account_id.to_string(),
carddav_url: format!("{}/addressbooks/contacts/", mock_server.uri()),
username: "james.maes".to_string(),
auth_token: "wiremock-app-token".to_string().into(),
};
CardDavClient::new(config)
.fetch_remote_vcards(account_id)
.await
}
#[tokio::test]
async fn wiremock_carddav_report_reads_the_same_document_under_any_namespace_prefix() {
for (label, body) in [
(
"uppercase prefixes",
multistatus_body_with_uppercase_prefixes(),
),
(
"default namespace",
multistatus_body_with_default_namespace(),
),
] {
let contacts = fetch_against_body(body, "acct-wm-ns")
.await
.unwrap_or_else(|e| panic!("{label} fetches: {e}"));
assert_eq!(contacts.len(), 2, "{label}");
assert!(
contacts.iter().any(|c| c.display_name == "James Maes"),
"{label}"
);
}
}
#[tokio::test]
async fn wiremock_carddav_report_ignores_a_property_that_merely_contains_the_searched_name() {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/addressbooks/contacts/lookalike.vcf</d:href>
<d:propstat>
<d:prop>
<card:address-data-summary>BEGIN:VCARD
VERSION:4.0
FN:Decoy Contact
END:VCARD</card:address-data-summary>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/addressbooks/contacts/alice.vcf</d:href>
<d:propstat>
<d:prop>
<card:address-data>BEGIN:VCARD
VERSION:4.0
FN:Alice Dev
EMAIL:alice@nuncio.mx
END:VCARD</card:address-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let contacts = fetch_against_body(body.to_string(), "acct-wm-lookalike")
.await
.expect("fetch succeeds");
assert_eq!(contacts.len(), 1, "the decoy element is not address-data");
assert_eq!(contacts[0].display_name, "Alice Dev");
}
#[tokio::test]
async fn wiremock_carddav_report_treats_a_404_propstat_as_no_value() {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/addressbooks/contacts/gone.vcf</d:href>
<d:propstat>
<d:prop>
<card:address-data>BEGIN:VCARD
VERSION:4.0
FN:Should Not Appear
END:VCARD</card:address-data>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/addressbooks/contacts/alice.vcf</d:href>
<d:propstat>
<d:prop>
<card:address-data>BEGIN:VCARD
VERSION:4.0
FN:Alice Dev
EMAIL:alice@nuncio.mx
END:VCARD</card:address-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let contacts = fetch_against_body(body.to_string(), "acct-wm-404")
.await
.expect("fetch succeeds");
assert_eq!(contacts.len(), 1, "a 404 propstat carries no value");
assert_eq!(contacts[0].display_name, "Alice Dev");
}
#[tokio::test]
async fn wiremock_carddav_report_malformed_body_surfaces_as_an_error_not_an_empty_address_book() {
// Well-formed HTTP, unreadable body: the `<d:response>` element is never closed.
let truncated = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:"><d:response><d:href>/addressbooks/contacts/a.vcf</d:href>"#;
let err = fetch_against_body(truncated.to_string(), "acct-wm-malformed")
.await
.expect_err("a body this client cannot read must not look like an empty address book");
assert!(matches!(err, CardDavError::MalformedResponse(_)));
// A 200 response that is not a multistatus at all (an intercepting captive portal, say).
let not_dav = "<html><body>Sign in to continue</body></html>";
let err = fetch_against_body(not_dav.to_string(), "acct-wm-html")
.await
.expect_err("a non-multistatus body must not look like an empty address book");
assert!(matches!(err, CardDavError::MalformedResponse(_)));
}
+1
View File
@@ -28,6 +28,7 @@ tar.workspace = true
regex.workspace = true
zip.workspace = true
chrono.workspace = true
quick-xml.workspace = true
[dev-dependencies]
wiremock.workspace = true
+753
View File
@@ -0,0 +1,753 @@
//! Namespace-aware WebDAV `multistatus` response parsing (RFC 4918 Section 13),
//! shared by the CalDAV (RFC 4791) and CardDAV (RFC 6352) clients.
//!
//! Both protocols return the same document shape --
//! `multistatus`/`response`/`propstat`/`prop` -- and differ only in which property
//! carries the payload (`calendar-data` vs `address-data`), so the traversal lives
//! here once rather than being reimplemented per protocol.
//!
//! Elements are matched on their resolved `{namespace}local-name`, never on a prefix
//! and never on a raw substring of the serialized document. `<D:response>`,
//! `<d:response>` and `<response xmlns="DAV:">` are therefore the same element, while
//! an element whose name merely *contains* a searched name (say
//! `<c:calendar-data-summary>`) is correctly a different one.
//!
//! Nothing is dropped in silence. A body that cannot be understood is an error, and a
//! `<response>` that carries no readable value for the requested property is reported
//! in [`MultistatusReport::skipped`] with the reason, so the caller can log the
//! omission instead of quietly returning a short list.
use std::fmt;
use quick_xml::escape::resolve_predefined_entity;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::ResolveResult;
use quick_xml::NsReader;
use thiserror::Error;
/// The WebDAV core namespace (RFC 4918).
pub const DAV_NS: &str = "DAV:";
/// The CalDAV namespace (RFC 4791).
pub const CALDAV_NS: &str = "urn:ietf:params:xml:ns:caldav";
/// The CardDAV namespace (RFC 6352).
pub const CARDDAV_NS: &str = "urn:ietf:params:xml:ns:carddav";
/// Failure to read a WebDAV `multistatus` body at all.
///
/// Both variants are returned instead of an empty result set: a server whose response
/// this parser cannot understand must never look to the caller like a server with
/// nothing to return.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum MultistatusError {
/// The body is not well-formed XML, or it nests markup where the payload property
/// is defined to hold character data only.
#[error("malformed WebDAV multistatus XML: {0}")]
Malformed(String),
/// The body parsed as XML but its root element is not `{DAV:}multistatus` -- for
/// example an HTML error page, or a `multistatus` in no namespace at all.
#[error("response root element is `{0}`, not `{{DAV:}}multistatus`")]
NotMultistatus(String),
}
/// Why a `<response>` yielded no value for the requested property.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
/// The property was named under a `<propstat>` (or the `<response>` itself carried
/// a status) whose HTTP status was not 2xx, so its content is not a value. A `404`
/// propstat is the usual case (RFC 4918 Section 13).
Status(String),
/// The property was returned under a successful status but held no content.
EmptyValue,
/// No `<propstat>` in the response named the requested property at all.
Absent,
}
impl fmt::Display for SkipReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Status(status) => write!(f, "non-success propstat status: {status}"),
Self::EmptyValue => write!(f, "property returned empty"),
Self::Absent => write!(f, "property absent from the response"),
}
}
}
/// One readable value of the requested property, with the `<href>` of the resource it
/// came from when the server supplied one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PropertyValue {
/// The enclosing `<response>`'s `<href>`, if it had one.
pub href: Option<String>,
/// The property's character content, trimmed of surrounding whitespace.
pub value: String,
}
/// A `<response>` that produced no value, and why.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedResponse {
/// The `<response>`'s `<href>`, if it had one.
pub href: Option<String>,
/// Why no value was taken from it.
pub reason: SkipReason,
}
/// The outcome of reading one `multistatus` body: everything readable, plus an
/// explicit account of everything that was not.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MultistatusReport {
/// Values of the requested property, in document order.
pub values: Vec<PropertyValue>,
/// Responses that carried no readable value for the requested property.
pub skipped: Vec<SkippedResponse>,
}
/// A qualified name resolved to `(namespace, local-name)`. A `None` namespace means the
/// element was in no namespace at all, which never matches a DAV element.
type Qname = (Option<String>, String);
/// Does this resolved name equal `{namespace}local`?
fn matches(name: &Qname, namespace: &str, local: &str) -> bool {
name.0.as_deref() == Some(namespace) && name.1 == local
}
/// Render a resolved name in Clark notation for diagnostics.
fn render(name: &Qname) -> String {
match &name.0 {
Some(ns) => format!("{{{ns}}}{}", name.1),
None => name.1.clone(),
}
}
/// `<D:status>` carries a full HTTP status line ("HTTP/1.1 200 OK"). Its content counts
/// as a value only when that line's code is 2xx; a status line with no readable code is
/// treated as a failure rather than optimistically accepted.
fn is_success_status(status_line: &str) -> bool {
status_line
.split_whitespace()
.find_map(|token| token.parse::<u16>().ok())
.is_some_and(|code| (200..300).contains(&code))
}
/// Which piece of character data the walker is currently accumulating.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CaptureKind {
Href,
ResponseStatus,
PropstatStatus,
Property,
}
/// One `<propstat>` in progress.
#[derive(Debug, Default)]
struct PropstatAcc {
status: Option<String>,
values: Vec<String>,
}
/// One `<response>` in progress.
#[derive(Debug, Default)]
struct ResponseAcc {
href: Option<String>,
status: Option<String>,
values: Vec<String>,
rejected_status: Option<String>,
saw_empty_value: bool,
}
impl ResponseAcc {
/// Fold a finished `<propstat>` into this response, honouring its HTTP status.
fn absorb(&mut self, propstat: PropstatAcc) {
if propstat.values.is_empty() {
return;
}
// RFC 4918 requires a `<status>`; a server that omits one is given the benefit of
// the doubt, since only an explicitly non-2xx status makes the content a non-value.
let succeeded = propstat.status.as_deref().is_none_or(is_success_status);
if !succeeded {
// Keep the first rejecting status so the caller can say *why* nothing came back.
if self.rejected_status.is_none() {
self.rejected_status = propstat.status;
}
return;
}
for value in propstat.values {
if value.is_empty() {
self.saw_empty_value = true;
} else {
self.values.push(value);
}
}
}
/// Emit this response's values, or a single explanation of why it produced none.
fn finish(self, report: &mut MultistatusReport) {
let Self {
href,
status,
values,
rejected_status,
saw_empty_value,
} = self;
if !values.is_empty() {
report
.values
.extend(values.into_iter().map(|value| PropertyValue {
href: href.clone(),
value,
}));
return;
}
let reason = match (rejected_status, saw_empty_value, status) {
(Some(status), _, _) => SkipReason::Status(status),
(None, true, _) => SkipReason::EmptyValue,
(None, false, Some(status)) if !is_success_status(&status) => {
SkipReason::Status(status)
}
_ => SkipReason::Absent,
};
report.skipped.push(SkippedResponse { href, reason });
}
}
/// Streaming state for one pass over a `multistatus` document.
struct Walker<'p> {
property_ns: &'p str,
property_local: &'p str,
stack: Vec<Qname>,
saw_root: bool,
response: Option<ResponseAcc>,
propstat: Option<PropstatAcc>,
capture: Option<CaptureKind>,
capture_depth: usize,
buf: String,
report: MultistatusReport,
}
impl<'p> Walker<'p> {
fn new(property_ns: &'p str, property_local: &'p str) -> Self {
Self {
property_ns,
property_local,
stack: Vec::new(),
saw_root: false,
response: None,
propstat: None,
capture: None,
capture_depth: 0,
buf: String::new(),
report: MultistatusReport::default(),
}
}
fn begin_capture(&mut self, kind: CaptureKind) {
self.capture = Some(kind);
self.capture_depth = self.stack.len() + 1;
self.buf.clear();
}
/// Is the element `levels` above the element about to be opened the given name?
/// `levels == 1` is its parent.
fn ancestor_is(&self, levels: usize, namespace: &str, local: &str) -> bool {
self.stack
.len()
.checked_sub(levels)
.and_then(|index| self.stack.get(index))
.is_some_and(|name| matches(name, namespace, local))
}
fn start(&mut self, name: Qname) -> Result<(), MultistatusError> {
if !self.saw_root {
if !matches(&name, DAV_NS, "multistatus") {
return Err(MultistatusError::NotMultistatus(render(&name)));
}
self.saw_root = true;
self.stack.push(name);
return Ok(());
}
// The payload property is defined as character data only; nested markup would be
// dropped by a text-only capture, which is exactly the silent loss being avoided.
if self.capture == Some(CaptureKind::Property) {
return Err(MultistatusError::Malformed(format!(
"unexpected element `{}` inside the `{}` property, which holds character data only",
render(&name),
self.property_local
)));
}
if self.ancestor_is(1, DAV_NS, "multistatus") && matches(&name, DAV_NS, "response") {
self.response = Some(ResponseAcc::default());
} else if self.ancestor_is(1, DAV_NS, "response") {
if matches(&name, DAV_NS, "href") {
self.begin_capture(CaptureKind::Href);
} else if matches(&name, DAV_NS, "status") {
self.begin_capture(CaptureKind::ResponseStatus);
} else if matches(&name, DAV_NS, "propstat") {
self.propstat = Some(PropstatAcc::default());
}
} else if self.ancestor_is(1, DAV_NS, "propstat") && matches(&name, DAV_NS, "status") {
self.begin_capture(CaptureKind::PropstatStatus);
} else if self.ancestor_is(1, DAV_NS, "prop")
&& self.ancestor_is(2, DAV_NS, "propstat")
&& matches(&name, self.property_ns, self.property_local)
{
self.begin_capture(CaptureKind::Property);
}
self.stack.push(name);
Ok(())
}
fn end(&mut self, name: &Qname) {
if let Some(kind) = self.capture {
if self.stack.len() == self.capture_depth {
let text = std::mem::take(&mut self.buf);
let trimmed = text.trim().to_string();
match kind {
CaptureKind::Href => {
if let Some(response) = self.response.as_mut() {
response.href = Some(trimmed);
}
}
CaptureKind::ResponseStatus => {
if let Some(response) = self.response.as_mut() {
response.status = Some(trimmed);
}
}
CaptureKind::PropstatStatus => {
if let Some(propstat) = self.propstat.as_mut() {
propstat.status = Some(trimmed);
}
}
CaptureKind::Property => {
if let Some(propstat) = self.propstat.as_mut() {
propstat.values.push(trimmed);
}
}
}
self.capture = None;
}
}
self.stack.pop();
if matches(name, DAV_NS, "propstat") && self.ancestor_is(1, DAV_NS, "response") {
if let (Some(propstat), Some(response)) = (self.propstat.take(), self.response.as_mut())
{
response.absorb(propstat);
}
} else if matches(name, DAV_NS, "response") && self.ancestor_is(1, DAV_NS, "multistatus") {
if let Some(response) = self.response.take() {
response.finish(&mut self.report);
}
}
}
fn push_text(&mut self, text: &str) {
if self.capture.is_some() {
self.buf.push_str(text);
}
}
}
/// Resolve one element's qualified name, refusing an undeclared prefix outright: a
/// prefix with no in-scope binding is not well-formed XML and must not be guessed at.
fn resolved_name(
resolve: &ResolveResult<'_>,
element: &BytesStart<'_>,
) -> Result<Qname, MultistatusError> {
let local = String::from_utf8_lossy(element.local_name().into_inner()).into_owned();
let namespace = match resolve {
ResolveResult::Bound(ns) => Some(String::from_utf8_lossy(ns.0).into_owned()),
ResolveResult::Unbound => None,
ResolveResult::Unknown(prefix) => {
return Err(MultistatusError::Malformed(format!(
"undeclared namespace prefix `{}` on element `{local}`",
String::from_utf8_lossy(prefix)
)))
}
};
Ok((namespace, local))
}
/// Read a WebDAV `multistatus` body, extracting every value of
/// `{property_namespace}property_local_name` returned under a successful `<propstat>`.
///
/// Returns [`MultistatusError`] rather than an empty report when the body is not a
/// `multistatus` document this parser can traverse, and records every `<response>` that
/// produced no value in [`MultistatusReport::skipped`] so the caller can surface it.
pub fn parse_multistatus(
raw_xml: &str,
property_namespace: &str,
property_local_name: &str,
) -> Result<MultistatusReport, MultistatusError> {
let mut reader = NsReader::from_str(raw_xml);
let mut walker = Walker::new(property_namespace, property_local_name);
loop {
let (resolve, event) = reader
.read_resolved_event()
.map_err(|e| MultistatusError::Malformed(e.to_string()))?;
match &event {
Event::Start(element) => {
let name = resolved_name(&resolve, element)?;
walker.start(name)?;
}
Event::Empty(element) => {
let name = resolved_name(&resolve, element)?;
walker.start(name.clone())?;
walker.end(&name);
}
Event::End(_) => {
// The reader already checked that this end tag matches its start tag, so the
// name on the walker's stack is authoritative and the resolver -- whose
// bindings have by now gone out of scope -- is not consulted again here.
let name = walker.stack.last().cloned().unwrap_or_default();
walker.end(&name);
}
Event::Text(text) => {
let decoded = text
.decode()
.map_err(|e| MultistatusError::Malformed(e.to_string()))?;
walker.push_text(&decoded);
}
Event::CData(cdata) => {
let decoded = cdata
.decode()
.map_err(|e| MultistatusError::Malformed(e.to_string()))?;
walker.push_text(&decoded);
}
Event::GeneralRef(reference) => {
let name = reference
.decode()
.map_err(|e| MultistatusError::Malformed(e.to_string()))?;
if let Some(character) = reference
.resolve_char_ref()
.map_err(|e| MultistatusError::Malformed(e.to_string()))?
{
walker.push_text(&character.to_string());
} else if let Some(expansion) = resolve_predefined_entity(&name) {
walker.push_text(expansion);
} else {
// Nuncio declares no DTD, so an unknown entity has no expansion; a
// best-effort guess would corrupt the payload it appears in.
return Err(MultistatusError::Malformed(format!(
"unresolvable entity reference `&{name};`"
)));
}
}
Event::Eof => break,
_ => {}
}
}
if !walker.saw_root {
return Err(MultistatusError::NotMultistatus("<none>".to_string()));
}
if !walker.stack.is_empty() {
return Err(MultistatusError::Malformed(format!(
"document ended with {} element(s) still open",
walker.stack.len()
)));
}
Ok(walker.report)
}
#[cfg(test)]
mod tests {
use super::*;
const CAL_DATA: &str = "calendar-data";
fn parse_cal(xml: &str) -> Result<MultistatusReport, MultistatusError> {
parse_multistatus(xml, CALDAV_NS, CAL_DATA)
}
#[test]
fn matches_the_same_document_under_any_prefix() {
let lower = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:href>/a.ics</d:href><d:propstat><d:prop>
<c:calendar-data>ICS-A</c:calendar-data>
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
</d:multistatus>"#;
let upper = r#"<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav">
<D:response><D:href>/a.ics</D:href><D:propstat><D:prop>
<CAL:calendar-data>ICS-A</CAL:calendar-data>
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
</D:multistatus>"#;
let defaulted = r#"<?xml version="1.0"?>
<multistatus xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<response><href>/a.ics</href><propstat><prop>
<c:calendar-data>ICS-A</c:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat></response>
</multistatus>"#;
for (label, xml) in [("lower", lower), ("upper", upper), ("default", defaulted)] {
let report = parse_cal(xml).unwrap_or_else(|e| panic!("{label} parses: {e}"));
assert_eq!(report.values.len(), 1, "{label}");
assert_eq!(report.values[0].value, "ICS-A", "{label}");
assert_eq!(report.values[0].href.as_deref(), Some("/a.ics"), "{label}");
assert!(report.skipped.is_empty(), "{label}");
}
}
#[test]
fn a_default_namespace_on_the_payload_property_is_matched_too() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response><d:propstat><d:prop>
<calendar-data xmlns="urn:ietf:params:xml:ns:caldav">ICS-A</calendar-data>
</d:prop></d:propstat></d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert_eq!(report.values.len(), 1);
assert_eq!(report.values[0].value, "ICS-A");
}
#[test]
fn an_element_whose_name_merely_contains_the_searched_name_is_not_the_property() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:href>/a.ics</d:href><d:propstat><d:prop>
<c:calendar-data-summary>NOT-THE-PAYLOAD</c:calendar-data-summary>
</d:prop></d:propstat></d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert!(report.values.is_empty());
assert_eq!(report.skipped.len(), 1);
assert_eq!(report.skipped[0].reason, SkipReason::Absent);
}
#[test]
fn the_same_local_name_in_a_foreign_namespace_is_not_the_property() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:x="http://example.invalid/other">
<d:response><d:propstat><d:prop>
<x:calendar-data>WRONG-NAMESPACE</x:calendar-data>
</d:prop></d:propstat></d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert!(report.values.is_empty());
assert_eq!(report.skipped[0].reason, SkipReason::Absent);
}
#[test]
fn a_non_success_propstat_is_not_a_value() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/gone.ics</d:href>
<d:propstat>
<d:prop><c:calendar-data>STALE</c:calendar-data></d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert!(report.values.is_empty(), "a 404 propstat is not a value");
assert_eq!(report.skipped.len(), 1);
assert_eq!(report.skipped[0].href.as_deref(), Some("/gone.ics"));
assert_eq!(
report.skipped[0].reason,
SkipReason::Status("HTTP/1.1 404 Not Found".to_string())
);
}
#[test]
fn a_success_propstat_alongside_a_404_propstat_still_yields_its_value() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/a.ics</d:href>
<d:propstat>
<d:prop><c:calendar-data>ICS-A</c:calendar-data></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop><d:getcontentlanguage/></d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert_eq!(report.values.len(), 1);
assert_eq!(report.values[0].value, "ICS-A");
assert!(report.skipped.is_empty());
}
#[test]
fn a_response_level_failure_status_is_reported_not_dropped() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/missing.ics</d:href>
<d:status>HTTP/1.1 424 Failed Dependency</d:status>
</d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert_eq!(
report.skipped[0].reason,
SkipReason::Status("HTTP/1.1 424 Failed Dependency".to_string())
);
}
#[test]
fn an_empty_property_is_reported_rather_than_passed_on_as_a_value() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:href>/blank.ics</d:href><d:propstat><d:prop>
<c:calendar-data/>
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert!(report.values.is_empty());
assert_eq!(report.skipped[0].reason, SkipReason::EmptyValue);
}
#[test]
fn a_root_that_is_not_a_dav_multistatus_is_an_error_not_an_empty_result() {
let html = "<html><body>502 Bad Gateway</body></html>";
assert!(matches!(
parse_cal(html),
Err(MultistatusError::NotMultistatus(_))
));
// A `multistatus` in no namespace at all is not the DAV: element.
let unnamespaced = "<multistatus><response/></multistatus>";
assert!(matches!(
parse_cal(unnamespaced),
Err(MultistatusError::NotMultistatus(_))
));
assert!(matches!(
parse_cal(" "),
Err(MultistatusError::NotMultistatus(_))
));
}
#[test]
fn malformed_documents_are_errors_not_silent_empties() {
// Mismatched end tag.
let mismatched =
r#"<d:multistatus xmlns:d="DAV:"><d:response></d:respons></d:multistatus>"#;
assert!(matches!(
parse_cal(mismatched),
Err(MultistatusError::Malformed(_))
));
// Truncated mid-document (a connection cut short).
let truncated = r#"<d:multistatus xmlns:d="DAV:"><d:response><d:href>/a.ics</d:href>"#;
assert!(matches!(
parse_cal(truncated),
Err(MultistatusError::Malformed(_))
));
// Undeclared prefix: the document names a namespace it never bound.
let undeclared = r#"<d:multistatus xmlns:d="DAV:"><q:response/></d:multistatus>"#;
assert!(matches!(
parse_cal(undeclared),
Err(MultistatusError::Malformed(_))
));
}
#[test]
fn markup_nested_inside_the_payload_property_is_an_error() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:propstat><d:prop>
<c:calendar-data>BEGIN<d:oops/>END</c:calendar-data>
</d:prop></d:propstat></d:response>
</d:multistatus>"#;
assert!(matches!(
parse_cal(xml),
Err(MultistatusError::Malformed(_))
));
}
#[test]
fn entity_references_and_cdata_reach_the_value_intact() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:propstat><d:prop>
<c:calendar-data>SUMMARY:Tea &amp; Toast &#65;</c:calendar-data>
</d:prop></d:propstat></d:response>
<d:response><d:propstat><d:prop>
<c:calendar-data><![CDATA[SUMMARY:5 < 6]]></c:calendar-data>
</d:prop></d:propstat></d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert_eq!(report.values[0].value, "SUMMARY:Tea & Toast A");
assert_eq!(report.values[1].value, "SUMMARY:5 < 6");
}
#[test]
fn an_unresolvable_entity_reference_is_an_error() {
let xml = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:propstat><d:prop>
<c:calendar-data>&nosuchentity;</c:calendar-data>
</d:prop></d:propstat></d:response>
</d:multistatus>"#;
assert!(matches!(
parse_cal(xml),
Err(MultistatusError::Malformed(_))
));
}
#[test]
fn carddav_address_data_uses_the_same_traversal() {
let xml = r#"<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<D:response><D:href>/alice.vcf</D:href><D:propstat><D:prop>
<CARD:address-data>BEGIN:VCARD</CARD:address-data>
</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>
</D:multistatus>"#;
let report = parse_multistatus(xml, CARDDAV_NS, "address-data").expect("parses");
assert_eq!(report.values.len(), 1);
assert_eq!(report.values[0].value, "BEGIN:VCARD");
}
#[test]
fn a_property_outside_a_propstat_prop_is_not_taken_as_a_value() {
let xml = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<c:calendar-data>NOT-IN-A-PROPSTAT</c:calendar-data>
</d:response>
</d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert!(report.values.is_empty());
assert_eq!(report.skipped[0].reason, SkipReason::Absent);
}
#[test]
fn status_lines_are_classified_by_their_numeric_code() {
assert!(is_success_status("HTTP/1.1 200 OK"));
assert!(is_success_status("HTTP/2 207 Multi-Status"));
assert!(!is_success_status("HTTP/1.1 404 Not Found"));
assert!(!is_success_status("HTTP/1.1 500 Internal Server Error"));
assert!(!is_success_status("nonsense with no code"));
}
#[test]
fn skip_reasons_render_for_logging() {
assert!(SkipReason::Status("HTTP/1.1 404 Not Found".to_string())
.to_string()
.contains("404"));
assert!(!SkipReason::EmptyValue.to_string().is_empty());
assert!(!SkipReason::Absent.to_string().is_empty());
}
#[test]
fn an_empty_multistatus_yields_an_empty_report_not_an_error() {
let xml = r#"<d:multistatus xmlns:d="DAV:"></d:multistatus>"#;
let report = parse_cal(xml).expect("parses");
assert!(report.values.is_empty());
assert!(report.skipped.is_empty());
}
}
+5
View File
@@ -4,6 +4,7 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod config;
pub mod dav;
pub mod export;
pub mod mcp_policy;
pub mod model;
@@ -15,6 +16,10 @@ pub use config::{
AccountConfig, AccountProtocol, ConfigError, DavTransport, ImapSmtpTransport, JmapTransport,
TlsMode, Transport,
};
pub use dav::{
parse_multistatus, MultistatusError, MultistatusReport, PropertyValue, SkipReason,
SkippedResponse, CALDAV_NS, CARDDAV_NS, DAV_NS,
};
pub use export::{ExportEngine, ExportError, ExportFormat, ExportSummary};
pub use mcp_policy::{AgentPermissions, DataType, McpAgentPolicy};
pub use model::{Attachment, CalendarEvent, Contact, DaemonTelemetry, Email, Folder};