diff --git a/Cargo.lock b/Cargo.lock index b0ac32b..3c0c672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/nuncio-cal/Cargo.toml b/crates/nuncio-cal/Cargo.toml index f329efd..60f64b5 100644 --- a/crates/nuncio-cal/Cargo.toml +++ b/crates/nuncio-cal/Cargo.toml @@ -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 diff --git a/crates/nuncio-cal/src/caldav.rs b/crates/nuncio-cal/src/caldav.rs index 3c5ee2a..7740d40 100644 --- a/crates/nuncio-cal/src/caldav.rs +++ b/crates/nuncio-cal/src/caldav.rs @@ -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 `` response containing embedded VEVENT data. + /// Parse a CalDAV WebDAV XML `` 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 `` 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, 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, usize) { - let mut events = Vec::new(); - let mut dropped = 0usize; + ) -> Result<(Vec, usize), CalendarError> { + let report = parse_multistatus(raw_xml, CALDAV_NS, "calendar-data") + .map_err(|e| CalendarError::MalformedResponse(e.to_string()))?; - // Extract or text blocks - for block in raw_xml.split("") { - if let Some((ics_data, _)) = block.split_once("") { - 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 } } + /// 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#" + + /evt1.ics + {event_ics} + HTTP/1.1 200 OK +"# + ); + let default_ns = format!( + r#" + + /evt1.ics + {event_ics} + HTTP/1.1 200 OK +"# + ); + let odd_prefix = format!( + r#" + + /evt1.ics + {event_ics} + HTTP/1.1 200 OK +"# + ); + + 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#" + + + /lookalike.ics + + NOT THE PAYLOAD + HTTP/1.1 200 OK + + + /gone.ics + + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 404 Not Found + + "#; + + 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 `` element is never closed. + let truncated = r#" + /a.ics"#; + 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 = "502 Bad Gateway"; + assert!(matches!( + client.parse_multistatus_response("cal-work", not_dav), + Err(CalendarError::MalformedResponse(_)) + )); + } + #[test] fn format_query_timestamp_produces_ical_utc_form() { let formatted = diff --git a/crates/nuncio-cal/src/parser.rs b/crates/nuncio-cal/src/parser.rs index 605747b..8b6490e 100644 --- a/crates/nuncio-cal/src/parser.rs +++ b/crates/nuncio-cal/src/parser.rs @@ -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}")] diff --git a/crates/nuncio-cal/tests/wiremock_caldav_test.rs b/crates/nuncio-cal/tests/wiremock_caldav_test.rs index d9023c3..ea8778f 100644 --- a/crates/nuncio-cal/tests/wiremock_caldav_test.rs +++ b/crates/nuncio-cal/tests/wiremock_caldav_test.rs @@ -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(" String { + multistatus_body() + .replace(" Result, 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#" + + + /calendars/work/lookalike.ics + + + BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY:Decoy +END:VEVENT +END:VCALENDAR + + HTTP/1.1 200 OK + + + + /calendars/work/real.ics + + + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Wiremock UTC Review +DTSTART:20240301T120000Z +DTEND:20240301T130000Z +END:VEVENT +END:VCALENDAR + + HTTP/1.1 200 OK + + +"#; + + 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#" + + + /calendars/work/gone.ics + + + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Should Not Appear +DTSTART:20240301T120000Z +DTEND:20240301T130000Z +END:VEVENT +END:VCALENDAR + + HTTP/1.1 404 Not Found + + + + /calendars/work/real.ics + + + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Wiremock UTC Review +DTSTART:20240301T120000Z +DTEND:20240301T130000Z +END:VEVENT +END:VCALENDAR + + HTTP/1.1 200 OK + + +"#; + + 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 `` element is never closed. + let truncated = r#" +/calendars/work/a.ics"#; + + 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 = "Sign in to continue"; + 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(_))); +} diff --git a/crates/nuncio-contacts/Cargo.toml b/crates/nuncio-contacts/Cargo.toml index 3f38356..0818b74 100644 --- a/crates/nuncio-contacts/Cargo.toml +++ b/crates/nuncio-contacts/Cargo.toml @@ -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 } diff --git a/crates/nuncio-contacts/src/carddav.rs b/crates/nuncio-contacts/src/carddav.rs index 23ddfd1..9dcbd4f 100644 --- a/crates/nuncio-contacts/src/carddav.rs +++ b/crates/nuncio-contacts/src/carddav.rs @@ -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 `` 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, 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("") { - if let Some((vcard_data, _)) = block.split_once("") { - 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 + /// `` 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, 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 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#" + + /alice.vcf + {card} + HTTP/1.1 200 OK +"# + ); + let default_ns = format!( + r#" + + /alice.vcf + {card} + HTTP/1.1 200 OK +"# + ); + let odd_prefix = format!( + r#" + + /alice.vcf + {card} + HTTP/1.1 200 OK +"# + ); + + 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#" + + + /lookalike.vcf + + NOT THE PAYLOAD + HTTP/1.1 200 OK + + + /gone.vcf + + BEGIN:VCARD +END:VCARD + HTTP/1.1 404 Not Found + + "#; + + 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 `` element is never closed. + let truncated = r#" + /a.vcf"#; + 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 = "502 Bad Gateway"; + assert!(matches!( + client.parse_multistatus_response("acct-1", not_dav), + Err(CardDavError::MalformedResponse(_)) + )); + } + #[test] fn debug_impl_redacts_auth_token() { let config = test_config(); diff --git a/crates/nuncio-contacts/tests/wiremock_carddav_test.rs b/crates/nuncio-contacts/tests/wiremock_carddav_test.rs index dd64c64..e0fbe0e 100644 --- a/crates/nuncio-contacts/tests/wiremock_carddav_test.rs +++ b/crates/nuncio-contacts/tests/wiremock_carddav_test.rs @@ -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 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(" String { + multistatus_body() + .replace(" Result, 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#" + + + /addressbooks/contacts/lookalike.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Decoy Contact +END:VCARD + + HTTP/1.1 200 OK + + + + /addressbooks/contacts/alice.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Alice Dev +EMAIL:alice@nuncio.mx +END:VCARD + + HTTP/1.1 200 OK + + +"#; + + 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#" + + + /addressbooks/contacts/gone.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Should Not Appear +END:VCARD + + HTTP/1.1 404 Not Found + + + + /addressbooks/contacts/alice.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Alice Dev +EMAIL:alice@nuncio.mx +END:VCARD + + HTTP/1.1 200 OK + + +"#; + + 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 `` element is never closed. + let truncated = r#" +/addressbooks/contacts/a.vcf"#; + + 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 = "Sign in to continue"; + 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(_))); +} diff --git a/crates/nuncio-core/Cargo.toml b/crates/nuncio-core/Cargo.toml index 2b84b9b..055883b 100644 --- a/crates/nuncio-core/Cargo.toml +++ b/crates/nuncio-core/Cargo.toml @@ -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 diff --git a/crates/nuncio-core/src/dav.rs b/crates/nuncio-core/src/dav.rs new file mode 100644 index 0000000..8677dc9 --- /dev/null +++ b/crates/nuncio-core/src/dav.rs @@ -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. ``, +//! `` and `` are therefore the same element, while +//! an element whose name merely *contains* a searched name (say +//! ``) is correctly a different one. +//! +//! Nothing is dropped in silence. A body that cannot be understood is an error, and a +//! `` 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 `` yielded no value for the requested property. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkipReason { + /// The property was named under a `` (or the `` 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 `` 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 `` of the resource it +/// came from when the server supplied one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PropertyValue { + /// The enclosing ``'s ``, if it had one. + pub href: Option, + /// The property's character content, trimmed of surrounding whitespace. + pub value: String, +} + +/// A `` that produced no value, and why. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedResponse { + /// The ``'s ``, if it had one. + pub href: Option, + /// 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, + /// Responses that carried no readable value for the requested property. + pub skipped: Vec, +} + +/// 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); + +/// 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(), + } +} + +/// `` 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::().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 `` in progress. +#[derive(Debug, Default)] +struct PropstatAcc { + status: Option, + values: Vec, +} + +/// One `` in progress. +#[derive(Debug, Default)] +struct ResponseAcc { + href: Option, + status: Option, + values: Vec, + rejected_status: Option, + saw_empty_value: bool, +} + +impl ResponseAcc { + /// Fold a finished `` into this response, honouring its HTTP status. + fn absorb(&mut self, propstat: PropstatAcc) { + if propstat.values.is_empty() { + return; + } + // RFC 4918 requires a ``; 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, + saw_root: bool, + response: Option, + propstat: Option, + capture: Option, + 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 { + 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 ``. +/// +/// Returns [`MultistatusError`] rather than an empty report when the body is not a +/// `multistatus` document this parser can traverse, and records every `` 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 { + 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("".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 { + parse_multistatus(xml, CALDAV_NS, CAL_DATA) + } + + #[test] + fn matches_the_same_document_under_any_prefix() { + let lower = r#" + + /a.ics + ICS-A + HTTP/1.1 200 OK +"#; + let upper = r#" + + /a.ics + ICS-A + HTTP/1.1 200 OK +"#; + let defaulted = r#" + + /a.ics + ICS-A + HTTP/1.1 200 OK +"#; + + 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#" + + + ICS-A + +"#; + 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#" + + /a.ics + NOT-THE-PAYLOAD + +"#; + 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#" + + + WRONG-NAMESPACE + +"#; + 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#" + + + /gone.ics + + STALE + HTTP/1.1 404 Not Found + + +"#; + 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#" + + + /a.ics + + ICS-A + HTTP/1.1 200 OK + + + + HTTP/1.1 404 Not Found + + +"#; + 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#" + + + /missing.ics + HTTP/1.1 424 Failed Dependency + +"#; + 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#" + + /blank.ics + + HTTP/1.1 200 OK +"#; + 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 = "502 Bad Gateway"; + assert!(matches!( + parse_cal(html), + Err(MultistatusError::NotMultistatus(_)) + )); + + // A `multistatus` in no namespace at all is not the DAV: element. + let unnamespaced = ""; + 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#""#; + assert!(matches!( + parse_cal(mismatched), + Err(MultistatusError::Malformed(_)) + )); + + // Truncated mid-document (a connection cut short). + let truncated = r#"/a.ics"#; + assert!(matches!( + parse_cal(truncated), + Err(MultistatusError::Malformed(_)) + )); + + // Undeclared prefix: the document names a namespace it never bound. + let undeclared = r#""#; + assert!(matches!( + parse_cal(undeclared), + Err(MultistatusError::Malformed(_)) + )); + } + + #[test] + fn markup_nested_inside_the_payload_property_is_an_error() { + let xml = r#" + + + BEGINEND + +"#; + assert!(matches!( + parse_cal(xml), + Err(MultistatusError::Malformed(_)) + )); + } + + #[test] + fn entity_references_and_cdata_reach_the_value_intact() { + let xml = r#" + + + SUMMARY:Tea & Toast A + + + + +"#; + 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#" + + &nosuchentity; + +"#; + assert!(matches!( + parse_cal(xml), + Err(MultistatusError::Malformed(_)) + )); + } + + #[test] + fn carddav_address_data_uses_the_same_traversal() { + let xml = r#" + + /alice.vcf + BEGIN:VCARD + HTTP/1.1 200 OK +"#; + 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#" + + NOT-IN-A-PROPSTAT + +"#; + 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#""#; + let report = parse_cal(xml).expect("parses"); + assert!(report.values.is_empty()); + assert!(report.skipped.is_empty()); + } +} diff --git a/crates/nuncio-core/src/lib.rs b/crates/nuncio-core/src/lib.rs index b91cb1b..5ac489d 100644 --- a/crates/nuncio-core/src/lib.rs +++ b/crates/nuncio-core/src/lib.rs @@ -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};