fix(dav): parse multistatus with namespaces, not substrings #430
No files matched your search
Generated
+1
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 & Toast A</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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in new issue
Block a user