use std::{str::FromStr, string::FromUtf8Error};
use http::{
response::Parts, status::InvalidStatusCode, uri::PathAndQuery, Method, Request, StatusCode, Uri,
};
use http_body_util::BodyExt;
use hyper::body::Bytes;
use hyper_util::{
client::legacy::{connect::Connect, Client},
rt::TokioExecutor,
};
use percent_encoding::percent_decode_str;
use crate::{
names,
sd::DiscoverableService,
xmlutils::{
check_multistatus, get_newline_corrected_text, get_unquoted_href, parse_statusline,
quote_href, render_xml, render_xml_with_text,
},
Auth, FetchedResource, FetchedResourceContent, ItemDetails, PropertyName, ResourceType,
};
#[derive(thiserror::Error, Debug)]
pub enum RequestError {
#[error("error executing http request: {0}")]
Http(#[from] hyper::Error),
#[error("client error executing request: {0}")]
Client(#[from] hyper_util::client::legacy::Error),
#[error("error resolving authentication: {0}")]
BadAuth(#[from] std::io::Error),
}
#[derive(thiserror::Error, Debug)]
#[allow(clippy::module_name_repetitions)]
pub enum WebDavError {
#[error("error executing http request: {0}")]
Http(#[from] hyper::Error),
#[error("client error executing http request: {0}")]
Client(#[from] hyper_util::client::legacy::Error),
#[error("error resolving authentication: {0}")]
BadAuth(#[from] std::io::Error),
#[error("missing field '{0}' in response XML")]
MissingData(&'static str),
#[error("invalid status code in response: {0}")]
InvalidStatusCode(#[from] InvalidStatusCode),
#[error("could not parse XML response: {0}")]
Xml(#[from] roxmltree::Error),
#[error("http request returned {0}")]
BadStatusCode(http::StatusCode),
#[error("failed to build URL with the given input: {0}")]
InvalidInput(#[from] http::Error),
#[error("the server returned an response with an invalid etag header: {0}")]
InvalidEtag(#[from] FromUtf8Error),
#[error("the server returned an invalid response: {0}")]
InvalidResponse(Box<dyn std::error::Error + Send + Sync>),
#[error("could not decode response as utf-8: {0}")]
NotUtf8(#[from] std::str::Utf8Error),
}
impl From<StatusCode> for WebDavError {
fn from(status: StatusCode) -> Self {
WebDavError::BadStatusCode(status)
}
}
impl From<RequestError> for WebDavError {
fn from(value: RequestError) -> Self {
match value {
RequestError::Http(err) => WebDavError::Http(err),
RequestError::Client(err) => WebDavError::Client(err),
RequestError::BadAuth(err) => WebDavError::BadAuth(err),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum ResolveContextPathError {
#[error("failed to create uri and request with given parameters: {0}")]
BadInput(#[from] http::Error),
#[error("error performing http request: {0}")]
Request(#[from] RequestError),
#[error("missing Location header in response")]
MissingLocation,
#[error("error building new Uri with Location from response: {0}")]
BadLocation(#[from] http::uri::InvalidUri),
}
#[derive(thiserror::Error, Debug)]
pub enum FindCurrentUserPrincipalError {
#[error("error performing webdav request: {0}")]
RequestError(#[from] WebDavError),
#[error("cannot use base_url to build request uri: {0}")]
InvalidInput(#[from] http::Error),
}
#[derive(Debug, Clone)]
pub struct WebDavClient<C>
where
C: Connect + Clone + Sync + Send + 'static,
{
pub base_url: Uri,
auth: Auth,
http_client: Client<C, String>,
}
impl<C> WebDavClient<C>
where
C: Connect + Clone + Sync + Send,
{
pub fn new(base_url: Uri, auth: Auth, connector: C) -> WebDavClient<C> {
WebDavClient {
base_url,
auth,
http_client: Client::builder(TokioExecutor::new()).build(connector),
}
}
pub fn base_url(&self) -> &Uri {
&self.base_url
}
pub fn relative_uri(&self, path: impl AsRef<str>) -> Result<Uri, http::Error> {
let href = quote_href(path.as_ref().as_bytes());
let mut parts = self.base_url.clone().into_parts();
parts.path_and_query = Some(PathAndQuery::try_from(href.as_ref())?);
Uri::from_parts(parts).map_err(http::Error::from)
}
pub async fn find_current_user_principal(
&self,
) -> Result<Option<Uri>, FindCurrentUserPrincipalError> {
let maybe_principal = self
.find_href_prop_as_uri(&self.base_url, &names::CURRENT_USER_PRINCIPAL)
.await;
match maybe_principal {
Err(WebDavError::BadStatusCode(StatusCode::NOT_FOUND)) | Ok(None) => {}
Err(err) => return Err(FindCurrentUserPrincipalError::RequestError(err)),
Ok(Some(p)) => return Ok(Some(p)),
};
let root = self.relative_uri("/")?;
self.find_href_prop_as_uri(&root, &names::CURRENT_USER_PRINCIPAL)
.await
.map_err(FindCurrentUserPrincipalError::RequestError)
}
pub(crate) async fn find_href_prop_as_uri(
&self,
url: &Uri,
property: &PropertyName<'_, '_>,
) -> Result<Option<Uri>, WebDavError> {
let (head, body) = self.propfind(url, &[property], 0).await?;
check_status(head.status)?;
parse_prop_href(body, url, property)
}
pub(crate) async fn find_hrefs_prop_as_uri(
&self,
url: &Uri,
property: &PropertyName<'_, '_>,
) -> Result<Vec<Uri>, WebDavError> {
let (head, body) = self.propfind(url, &[property], 0).await?;
check_status(head.status)?;
let body = body;
let body = std::str::from_utf8(body.as_ref())?;
let doc = roxmltree::Document::parse(body)?;
let root = doc.root_element();
let props = root
.descendants()
.filter(|node| node.tag_name() == *property)
.collect::<Vec<_>>();
if props.len() == 1 {
let mut hrefs = Vec::new();
let href_nodes = props[0]
.children()
.filter(|node| node.tag_name() == names::HREF);
for href_node in href_nodes {
let maybe_href = href_node
.text()
.map(|raw| percent_decode_str(raw).decode_utf8())
.transpose()?;
let Some(href) = maybe_href else {
continue;
};
let path = PathAndQuery::from_str(&href)
.map_err(|e| WebDavError::InvalidResponse(Box::from(e)))?;
let mut parts = url.clone().into_parts();
parts.path_and_query = Some(path);
let href = (Uri::from_parts(parts))
.map_err(|e| WebDavError::InvalidResponse(Box::from(e)))?;
hrefs.push(href);
}
return Ok(hrefs);
}
check_multistatus(root)?;
Err(WebDavError::InvalidResponse(
"missing property in response but no error".into(),
))
}
pub async fn propfind(
&self,
url: &Uri,
properties: &[&PropertyName<'_, '_>],
depth: u8,
) -> Result<(Parts, Bytes), WebDavError> {
let mut body = String::from(r#"<propfind xmlns="DAV:"><prop>"#);
for prop in properties {
body.push_str(&render_xml(prop));
}
body.push_str("</prop></propfind>");
let request = Request::builder()
.method("PROPFIND")
.uri(url)
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", depth.to_string())
.body(body)?;
self.request(request).await.map_err(WebDavError::from)
}
pub async fn request(&self, request: Request<String>) -> Result<(Parts, Bytes), RequestError> {
let response = self.http_client.request(self.auth.apply(request)?).await?;
let (head, body) = response.into_parts();
let body = body.collect().await?.to_bytes();
log::trace!("Response ({}): {:?}", head.status, body);
Ok((head, body))
}
pub async fn get_property(
&self,
href: &str,
property: &PropertyName<'_, '_>,
) -> Result<Option<String>, WebDavError> {
let url = self.relative_uri(href)?;
let (head, body) = self.propfind(&url, &[property], 0).await?;
check_status(head.status)?;
parse_prop(body, property)
}
pub async fn get_properties<'p>(
&self,
href: &str,
properties: &[&PropertyName<'p, 'p>],
) -> Result<Vec<(PropertyName<'p, 'p>, Option<String>)>, WebDavError> {
let url = self.relative_uri(href)?;
let (head, body) = self.propfind(&url, properties, 0).await?;
check_status(head.status)?;
let body = std::str::from_utf8(body.as_ref())?;
let doc = roxmltree::Document::parse(body)?;
let root = doc.root_element();
let mut results = Vec::with_capacity(properties.len());
for property in properties {
let prop = root
.descendants()
.find(|node| node.tag_name() == **property)
.or_else(|| {
root.descendants()
.find(|node| node.tag_name().name() == property.name())
})
.and_then(|p| p.text())
.map(str::to_owned);
results.push((**property, prop));
}
Ok(results)
}
pub async fn set_property(
&self,
href: &str,
property: &PropertyName<'_, '_>,
value: Option<&str>,
) -> Result<Option<String>, WebDavError> {
let url = self.relative_uri(href)?;
let action = match value {
Some(_) => "set",
None => "remove",
};
let inner = render_xml_with_text(property, value);
let request = Request::builder()
.method("PROPPATCH")
.uri(url)
.header("Content-Type", "application/xml; charset=utf-8")
.body(format!(
r#"<propertyupdate xmlns="DAV:">
<{action}>
<prop>
{inner}
</prop>
</{action}>
</propertyupdate>"#
))?;
let (head, body) = self.request(request).await?;
check_status(head.status)?;
parse_prop(body, property)
}
#[allow(clippy::missing_panics_doc)] pub async fn find_context_path(
&self,
service: DiscoverableService,
host: &str,
port: u16,
) -> Result<Option<Uri>, ResolveContextPathError> {
let uri = Uri::builder()
.scheme(service.scheme())
.authority(format!("{host}:{port}"))
.path_and_query(service.well_known_path())
.build()?;
let request = Request::builder()
.method(Method::GET)
.uri(uri)
.body(String::new())?;
let (head, _body) = self.request(request).await?;
log::debug!("Response finding context path: {}", head.status);
if !head.status.is_redirection() {
return Ok(None);
}
let location = head
.headers
.get(hyper::header::LOCATION)
.ok_or(ResolveContextPathError::MissingLocation)?
.as_bytes();
let uri = Uri::try_from(location)?;
if uri.host().is_some() {
return Ok(Some(uri)); }
let mut parts = uri.into_parts();
if parts.scheme.is_none() {
parts.scheme = Some(service.scheme());
}
if parts.authority.is_none() {
parts.authority = Some(format!("{host}:{port}").try_into()?);
}
let uri = Uri::from_parts(parts).expect("uri parts are already validated");
Ok(Some(uri))
}
pub async fn list_resources(
&self,
collection_href: &str,
) -> Result<Vec<ListedResource>, WebDavError> {
let url = self.relative_uri(collection_href)?;
let (head, body) = self
.propfind(
&url,
&[
&names::RESOURCETYPE,
&names::GETCONTENTTYPE,
&names::GETETAG,
],
1,
)
.await?;
check_status(head.status)?;
list_resources_parse(body, collection_href)
}
async fn put(
&self,
href: impl AsRef<str>,
data: Vec<u8>,
etag: Option<impl AsRef<str>>,
mime_type: impl AsRef<[u8]>,
) -> Result<Option<String>, WebDavError> {
let mut builder = Request::builder()
.method(Method::PUT)
.uri(self.relative_uri(href)?)
.header("Content-Type", mime_type.as_ref());
builder = match etag {
Some(etag) => builder.header("If-Match", etag.as_ref()),
None => builder.header("If-None-Match", "*"),
};
let request = String::from_utf8(data)
.map_err(|e| WebDavError::NotUtf8(e.utf8_error()))
.map(|string| builder.body(string))??;
let (head, _body) = self.request(request).await?;
check_status(head.status)?;
let new_etag = head
.headers
.get("etag")
.map(|hv| String::from_utf8(hv.as_bytes().to_vec()))
.transpose()?;
Ok(new_etag)
}
pub async fn create_resource(
&self,
href: impl AsRef<str>,
data: Vec<u8>,
mime_type: impl AsRef<[u8]>,
) -> Result<Option<String>, WebDavError> {
self.put(href, data, Option::<&str>::None, mime_type).await
}
pub async fn update_resource(
&self,
href: impl AsRef<str>,
data: Vec<u8>,
etag: impl AsRef<str>,
mime_type: impl AsRef<[u8]>,
) -> Result<Option<String>, WebDavError> {
self.put(href, data, Some(etag.as_ref()), mime_type).await
}
pub async fn create_collection(
&self,
href: impl AsRef<str>,
resourcetypes: &[&PropertyName<'_, '_>],
) -> Result<(), WebDavError> {
let mut rendered_resource_types = String::new();
for resource_type in resourcetypes {
rendered_resource_types.push_str(&render_xml(resource_type));
}
let body = format!(
r#"
<mkcol xmlns="DAV:">
<set>
<prop>
<resourcetype>
<collection/>
{rendered_resource_types}
</resourcetype>
</prop>
</set>
</mkcol>"#
);
let request = Request::builder()
.method("MKCOL")
.uri(self.relative_uri(href.as_ref())?)
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)?;
let (head, _body) = self.request(request).await?;
check_status(head.status)?;
Ok(())
}
pub async fn delete(
&self,
href: impl AsRef<str>,
etag: impl AsRef<str>,
) -> Result<(), WebDavError> {
let request = Request::builder()
.method(Method::DELETE)
.uri(self.relative_uri(href.as_ref())?)
.header("Content-Type", "application/xml; charset=utf-8")
.header("If-Match", etag.as_ref())
.body(String::new())?;
let (head, _body) = self.request(request).await?;
check_status(head.status).map_err(WebDavError::BadStatusCode)
}
pub async fn force_delete(&self, href: impl AsRef<str>) -> Result<(), WebDavError> {
let request = Request::builder()
.method(Method::DELETE)
.uri(self.relative_uri(href.as_ref())?)
.header("Content-Type", "application/xml; charset=utf-8")
.body(String::new())?;
let (head, _body) = self.request(request).await?;
check_status(head.status).map_err(WebDavError::BadStatusCode)
}
pub(crate) async fn multi_get(
&self,
collection_href: &str,
body: String,
property: &PropertyName<'_, '_>,
) -> Result<Vec<FetchedResource>, WebDavError> {
let request = Request::builder()
.method("REPORT")
.uri(self.relative_uri(collection_href)?)
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)?;
let (head, body) = self.request(request).await?;
check_status(head.status)?;
multi_get_parse(body, property)
}
}
#[inline]
pub(crate) fn check_status(status: StatusCode) -> Result<(), StatusCode> {
if status.is_success() {
Ok(())
} else {
Err(status)
}
}
pub mod mime_types {
pub const CALENDAR: &[u8] = b"text/calendar";
pub const ADDRESSBOOK: &[u8] = b"text/vcard";
}
#[derive(Debug, PartialEq)]
pub struct ListedResource {
pub details: ItemDetails,
pub href: String,
pub status: Option<StatusCode>,
}
#[derive(Debug)]
pub struct FoundCollection {
pub href: String,
pub etag: Option<String>,
pub supports_sync: bool,
}
pub(crate) fn parse_prop_href(
body: impl AsRef<[u8]>,
url: &Uri,
property: &PropertyName<'_, '_>,
) -> Result<Option<Uri>, WebDavError> {
let body = std::str::from_utf8(body.as_ref())?;
let doc = roxmltree::Document::parse(body)?;
let root = doc.root_element();
let props = root
.descendants()
.filter(|node| node.tag_name() == *property)
.collect::<Vec<_>>();
if props.len() == 1 {
if let Some(href_node) = props[0]
.children()
.find(|node| node.tag_name() == names::HREF)
{
let maybe_href = href_node
.text()
.map(|raw| percent_decode_str(raw).decode_utf8())
.transpose()?;
let Some(href) = maybe_href else {
return Ok(None);
};
let path = PathAndQuery::from_str(&href)
.map_err(|e| WebDavError::InvalidResponse(Box::from(e)))?;
let mut parts = url.clone().into_parts();
parts.path_and_query = Some(path);
return Some(Uri::from_parts(parts))
.transpose()
.map_err(|e| WebDavError::InvalidResponse(Box::from(e)));
}
}
check_multistatus(root)?;
Err(WebDavError::InvalidResponse(
"missing property in response but no error".into(),
))
}
fn parse_prop(
body: impl AsRef<[u8]>,
property: &PropertyName<'_, '_>,
) -> Result<Option<String>, WebDavError> {
let body = std::str::from_utf8(body.as_ref())?;
let doc = roxmltree::Document::parse(body)?;
let root = doc.root_element();
let prop = root
.descendants()
.find(|node| node.tag_name() == *property)
.or_else(|| {
root.descendants()
.find(|node| node.tag_name().name() == property.name())
});
if let Some(prop) = prop {
return Ok(prop.text().map(str::to_string));
}
check_multistatus(root)?;
Err(WebDavError::InvalidResponse(
"Property is missing from response, but response is non-error.".into(),
))
}
fn list_resources_parse(
body: impl AsRef<[u8]>,
collection_href: &str,
) -> Result<Vec<ListedResource>, WebDavError> {
let body = std::str::from_utf8(body.as_ref())?;
let doc = roxmltree::Document::parse(body)?;
let root = doc.root_element();
let responses = root
.descendants()
.filter(|node| node.tag_name() == names::RESPONSE);
let mut items = Vec::new();
for response in responses {
let href = get_unquoted_href(&response)?.to_string();
if href == collection_href {
continue;
}
let status = response
.descendants()
.find(|node| node.tag_name() == names::STATUS)
.and_then(|node| node.text().map(str::to_string))
.map(parse_statusline)
.transpose()?;
let etag = response
.descendants()
.find(|node| node.tag_name() == names::GETETAG)
.and_then(|node| node.text().map(str::to_string));
let content_type = response
.descendants()
.find(|node| node.tag_name() == names::GETCONTENTTYPE)
.and_then(|node| node.text().map(str::to_string));
let resource_type = if let Some(r) = response
.descendants()
.find(|node| node.tag_name() == names::RESOURCETYPE)
{
ResourceType {
is_calendar: r.descendants().any(|n| n.tag_name() == names::CALENDAR),
is_collection: r.descendants().any(|n| n.tag_name() == names::COLLECTION),
is_address_book: r.descendants().any(|n| n.tag_name() == names::ADDRESSBOOK),
}
} else {
ResourceType::default()
};
items.push(ListedResource {
details: ItemDetails {
content_type,
etag,
resource_type,
},
href,
status,
});
}
Ok(items)
}
fn multi_get_parse(
body: impl AsRef<[u8]>,
property: &PropertyName<'_, '_>,
) -> Result<Vec<FetchedResource>, WebDavError> {
let body = std::str::from_utf8(body.as_ref())?;
let doc = roxmltree::Document::parse(body)?;
let responses = doc
.root_element()
.descendants()
.filter(|node| node.tag_name() == names::RESPONSE);
let mut items = Vec::new();
for response in responses {
let status = match check_multistatus(response) {
Ok(()) => None,
Err(WebDavError::BadStatusCode(status)) => Some(status),
Err(e) => return Err(e),
};
let has_propstat = response .descendants()
.any(|node| node.tag_name() == names::PROPSTAT);
if has_propstat {
let href = get_unquoted_href(&response)?.to_string();
if let Some(status) = status {
items.push(FetchedResource {
href,
content: Err(status),
});
continue;
}
let etag = response
.descendants()
.find(|node| node.tag_name() == crate::names::GETETAG)
.ok_or(WebDavError::InvalidResponse(
"missing etag in response".into(),
))?
.text()
.ok_or(WebDavError::InvalidResponse("missing text in etag".into()))?
.to_string();
let data = get_newline_corrected_text(&response, property)?;
items.push(FetchedResource {
href,
content: Ok(FetchedResourceContent { data, etag }),
});
} else {
let hrefs = response
.descendants()
.filter(|node| node.tag_name() == names::HREF);
for href in hrefs {
let href = href
.text()
.map(percent_decode_str)
.ok_or(WebDavError::InvalidResponse("missing text in href".into()))?
.decode_utf8()?
.to_string();
let status = status.ok_or(WebDavError::InvalidResponse(
"missing props but no error status code".into(),
))?;
items.push(FetchedResource {
href,
content: Err(status),
});
}
}
}
Ok(items)
}
#[cfg(test)]
mod more_tests {
use http::{StatusCode, Uri};
use crate::{
dav::{list_resources_parse, multi_get_parse, parse_prop, parse_prop_href, ListedResource},
names::{self, CALENDAR_COLOUR, CALENDAR_DATA, CURRENT_USER_PRINCIPAL, DISPLAY_NAME},
FetchedResource, FetchedResourceContent, ItemDetails, ResourceType,
};
#[test]
fn test_multi_get_parse() {
let raw = br#"
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
<response>
<href>/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<C:calendar/>
</resourcetype>
<getcontenttype>text/calendar; charset=utf-8</getcontenttype>
<getetag>"1591712486-1-1"</getetag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/395b00a0-eebc-40fd-a98e-176a06367c82.ics</href>
<propstat>
<prop>
<resourcetype/>
<getcontenttype>text/calendar; charset=utf-8; component=VEVENT</getcontenttype>
<getetag>"e7577ff2b0924fe8e9a91d3fb2eb9072598bf9fb"</getetag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>"#;
let results = list_resources_parse(
raw,
"/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/",
)
.unwrap();
assert_eq!(results, vec![ListedResource {
details: ItemDetails {
content_type: Some("text/calendar; charset=utf-8; component=VEVENT".into()),
etag: Some("\"e7577ff2b0924fe8e9a91d3fb2eb9072598bf9fb\"".into()),
resource_type: ResourceType {
is_collection: false,
is_calendar: false,
is_address_book: false
},
},
href: "/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/395b00a0-eebc-40fd-a98e-176a06367c82.ics".into(),
status: Some(StatusCode::OK),
}]);
}
#[test]
fn test_list_resources_parse_404() {
let raw = br#"
<ns0:multistatus xmlns:ns0="DAV:">
<ns0:response>
<ns0:href>http%3A//2f746d702f736f636b6574/user/contacts/Default</ns0:href>
<ns0:status>HTTP/1.1 404 Not Found</ns0:status>
</ns0:response>
</ns0:multistatus>
"#;
let results = list_resources_parse(raw, "/user/contacts/Default").unwrap();
assert_eq!(
results,
vec![ListedResource {
details: ItemDetails {
content_type: None,
etag: None,
resource_type: ResourceType {
is_collection: false,
is_calendar: false,
is_address_book: false
}
},
href: "http://2f746d702f736f636b6574/user/contacts/Default".to_string(),
status: Some(StatusCode::NOT_FOUND),
}]
);
}
#[test]
fn test_multi_get_parse_with_err() {
let raw = br#"
<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:caldav">
<ns0:response>
<ns0:href>/user/calendars/Q208cKvMGjAdJFUw/qJJ9Li5DPJYr.ics</ns0:href>
<ns0:propstat>
<ns0:status>HTTP/1.1 200 OK</ns0:status>
<ns0:prop>
<ns0:getetag>"adb2da8d3cb1280a932ed8f8a2e8b4ecf66d6a02"</ns0:getetag>
<ns1:calendar-data>CALENDAR-DATA-HERE</ns1:calendar-data>
</ns0:prop>
</ns0:propstat>
</ns0:response>
<ns0:response>
<ns0:href>/user/calendars/Q208cKvMGjAdJFUw/rKbu4uUn.ics</ns0:href>
<ns0:status>HTTP/1.1 404 Not Found</ns0:status>
</ns0:response>
</ns0:multistatus>
"#;
let results = multi_get_parse(raw, &CALENDAR_DATA).unwrap();
assert_eq!(
results,
vec![
FetchedResource {
href: "/user/calendars/Q208cKvMGjAdJFUw/qJJ9Li5DPJYr.ics".into(),
content: Ok(FetchedResourceContent {
data: "CALENDAR-DATA-HERE".into(),
etag: "\"adb2da8d3cb1280a932ed8f8a2e8b4ecf66d6a02\"".into(),
})
},
FetchedResource {
href: "/user/calendars/Q208cKvMGjAdJFUw/rKbu4uUn.ics".into(),
content: Err(StatusCode::NOT_FOUND)
}
]
);
}
#[test]
fn test_multi_get_parse_mixed() {
let raw = br#"
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/remote.php/dav/calendars/vdirsyncer/1678996875/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:getetag/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let results = multi_get_parse(raw, &CALENDAR_DATA).unwrap();
assert_eq!(
results,
vec![FetchedResource {
href: "/remote.php/dav/calendars/vdirsyncer/1678996875/".into(),
content: Err(StatusCode::NOT_FOUND)
}]
);
}
#[test]
fn test_multi_get_parse_encoding() {
let b = r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<response>
<href>/dav/calendars/user/hugo@whynothugo.nl/2100F960-2655-4E75-870F-CAA793466105/0F276A13-FBF3-49A1-8369-65EEA9C6F891.ics</href>
<propstat>
<prop>
<getetag>"4219b87012f42ce7c4db55599aa3b579c70d8795"</getetag>
<C:calendar-data><![CDATA[BEGIN:VCALENDAR
CALSCALE:GREGORIAN
PRODID:-//Apple Inc.//iOS 17.0//EN
VERSION:2.0
BEGIN:VTODO
COMPLETED:20230425T155913Z
CREATED:20210622T182718Z
DTSTAMP:20230915T132714Z
LAST-MODIFIED:20230425T155913Z
PERCENT-COMPLETE:100
SEQUENCE:1
STATUS:COMPLETED
SUMMARY:Comidas: ñoquis, 西红柿
UID:0F276A13-FBF3-49A1-8369-65EEA9C6F891
X-APPLE-SORT-ORDER:28
END:VTODO
END:VCALENDAR
]]></C:calendar-data>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>"#;
let resources = multi_get_parse(b, &names::CALENDAR_DATA).unwrap();
let content = resources.into_iter().next().unwrap().content.unwrap();
assert!(content.data.contains("ñoquis"));
assert!(content.data.contains("西红柿"));
}
#[test]
fn test_multi_get_parse_encoding_another() {
let b = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<multistatus xmlns=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">\n <response>\n <href>/dav/calendars/user/hugo@whynothugo.nl/2100F960-2655-4E75-870F-CAA793466105/0F276A13-FBF3-49A1-8369-65EEA9C6F891.ics</href>\n <propstat>\n <prop>\n <getetag>\"4219b87012f42ce7c4db55599aa3b579c70d8795\"</getetag>\n <C:calendar-data><![CDATA[BEGIN(baño)END\r\n]]></C:calendar-data>\n </prop>\n <status>HTTP/1.1 200 OK</status>\n </propstat>\n </response>\n</multistatus>\n";
let resources = multi_get_parse(b, &names::CALENDAR_DATA).unwrap();
let content = resources.into_iter().next().unwrap().content.unwrap();
assert!(content.data.contains("baño"));
}
#[test]
fn test_parse_prop_href() {
let raw = br#"
<multistatus xmlns="DAV:">
<response>
<href>/dav/calendars</href>
<propstat>
<prop>
<current-user-principal>
<href>/dav/principals/user/vdirsyncer@example.com/</href>
</current-user-principal>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>"#;
let results = parse_prop_href(
raw,
&Uri::try_from("https://example.com/").unwrap(),
&CURRENT_USER_PRINCIPAL,
)
.unwrap();
assert_eq!(
results,
Some(
Uri::try_from("https://example.com/dav/principals/user/vdirsyncer@example.com/")
.unwrap()
)
);
}
#[test]
fn test_parse_prop_cdata() {
let raw = br#"
<multistatus xmlns="DAV:">
<response>
<href>/path</href>
<propstat>
<prop>
<displayname><![CDATA[test calendar]]></displayname>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
</multistatus>
"#;
let results = parse_prop(raw, &DISPLAY_NAME).unwrap();
assert_eq!(results, Some("test calendar".into()));
}
#[test]
fn test_parse_prop_text() {
let raw = br#"
<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="http://apple.com/ns/ical/">
<ns0:response>
<ns0:href>/user/calendars/pxE4Wt4twPqcWPbS/</ns0:href>
<ns0:propstat>
<ns0:status>HTTP/1.1 200 OK</ns0:status>
<ns0:prop>
<ns1:calendar-color>#ff00ff</ns1:calendar-color>
</ns0:prop>
</ns0:propstat>
</ns0:response>
</ns0:multistatus>"#;
let results = parse_prop(raw, &CALENDAR_COLOUR).unwrap();
assert_eq!(results, Some("#ff00ff".into()));
parse_prop(raw, &DISPLAY_NAME).unwrap_err();
}
#[test]
fn test_parse_prop() {
let body = concat!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
"<multistatus xmlns=\"DAV:\">",
"<response>",
"<href>/dav/calendars/user/hugo@whynothugo.nl/37c044e7-4b3d-4910-ba31-55038b413c7d/</href>",
"<propstat>",
"<prop>",
"<calendar-color><![CDATA[#FF2968]]></calendar-color>",
"</prop>",
"<status>HTTP/1.1 200 OK</status>",
"</propstat>",
"</response>",
"</multistatus>",
);
let parsed = parse_prop(body, &names::CALENDAR_COLOUR).unwrap();
assert_eq!(parsed, Some(String::from("#FF2968")));
}
}