// Copyright 2023-2024 Hugo Osvaldo Barrera
//
// SPDX-License-Identifier: ISC
//! Generic WebDAV implementation.
//!
//! This mostly implements the necessary bits for the CalDAV and CardDAV implementations. It should
//! not be considered a general purpose WebDAV implementation.
pub mod check_support;
pub mod delete;
pub mod find_collections;
pub mod find_property_hrefs;
pub mod get_etag;
pub mod get_properties;
pub mod get_property;
pub mod list_resources;
pub mod propfind;
pub mod put_resource;
pub mod set_property;
pub use check_support::{CheckSupport, CheckSupportParseError, CheckSupportResponse};
pub use delete::{Delete, DeleteResponse};
pub use find_collections::{FindCollections, FindCollectionsResponse};
pub use find_property_hrefs::{FindPropertyHrefs, FindPropertyHrefsResponse};
pub use get_etag::{GetEtag, GetEtagResponse};
pub use get_properties::{GetProperties, GetPropertiesResponse};
pub use get_property::{GetProperty, GetPropertyResponse};
pub use list_resources::{ListResources, ListResourcesResponse};
pub use propfind::{Propfind, PropfindResponse};
pub use put_resource::{PutResource, PutResourceParseError, PutResourceResponse};
pub use set_property::{SetProperty, SetPropertyResponse};
use std::{string::FromUtf8Error, sync::Arc};
use http::{
Method, Request, Response, StatusCode, Uri, response::Parts, status::InvalidStatusCode,
uri::PathAndQuery,
};
use http_body_util::BodyExt;
use hyper::body::{Bytes, Incoming};
use log::debug;
use tokio::sync::Mutex;
use tower_service::Service;
use crate::{
FetchedResource, FetchedResourceContent, Precondition, PropertyName, ResourceType,
encoding::{NormalisationError, normalise_percent_encoded, strict_percent_encoded},
names,
requests::{DavRequest, ParseResponseError},
sd::DiscoverableService,
xmlutils::{
check_multistatus, get_newline_corrected_text, get_normalised_href, parse_statusline,
},
};
/// Error executing an HTTP request.
#[derive(thiserror::Error, Debug)]
pub enum RequestError<E> {
/// Error handling the HTTP stream.
#[error("executing http request: {0}")]
Http(#[from] hyper::Error),
/// Error from the underlying HTTP client.
#[error("client error executing request: {0}")]
Client(E),
}
/// Error for WebDAV operations.
#[derive(thiserror::Error, Debug)]
pub enum WebDavError<E> {
/// Error performing underlying HTTP request.
#[error(transparent)]
Request(#[from] RequestError<E>),
/// An expected field was missing in the HTTP response.
#[error("missing field '{0}' in response XML")]
MissingData(&'static str),
/// The server returned an invalid status code.
#[error("invalid status code in response: {0}")]
InvalidStatusCode(#[from] InvalidStatusCode),
/// Error parsing the XML response.
#[error("parsing XML response: {0}")]
Xml(#[from] roxmltree::Error),
/// The server returned an unexpected status code.
#[error("http request returned {0}")]
BadStatusCode(http::StatusCode),
/// An argument passed to build a URL was invalid.
#[error("building URL with the given input: {0}")]
InvalidInput(#[from] http::Error),
/// The Etag from the server is not a valid UTF-8 string.
#[error("response contains an invalid etag header: {0}")]
InvalidEtag(#[from] FromUtf8Error),
/// The server returned a response that did not contain valid data.
#[error("invalid response: {0}")]
InvalidResponse(Box<dyn std::error::Error + Send + Sync>),
/// Server rejected request due to failed precondition.
#[error("precondition failed")]
PreconditionFailed(Precondition<'static>),
/// The response is not valid UTF-8.
///
/// At this time, other encodings are not supported.
#[error("decoding response as utf-8: {0}")]
NotUtf8(#[from] std::str::Utf8Error),
}
impl<E> From<StatusCode> for WebDavError<E> {
fn from(status: StatusCode) -> Self {
WebDavError::BadStatusCode(status)
}
}
impl<E> From<NormalisationError> for WebDavError<E> {
fn from(value: NormalisationError) -> Self {
WebDavError::InvalidResponse(value.into())
}
}
/// Error type for [`WebDavClient::find_context_path`].
#[derive(thiserror::Error, Debug)]
pub enum ResolveContextPathError<E> {
/// An argument passed to build a URL was invalid.
#[error("creating uri and request with given parameters: {0}")]
BadInput(#[from] http::Error),
/// Error performing underlying HTTP request.
#[error("performing http request: {0}")]
Request(#[from] RequestError<E>),
/// The response is missing a required Location header.
#[error("missing Location header in response")]
MissingLocation,
/// The Location from the server's response could not be used to build a new URL.
#[error("building new Uri with Location from response: {0}")]
BadLocation(#[from] http::uri::InvalidUri),
/// Too many redirections were encountered.
#[error("too many redirections")]
TooManyRedirects,
}
/// Error type for [`WebDavClient::find_current_user_principal`]
#[derive(thiserror::Error, Debug)]
pub enum FindCurrentUserPrincipalError<E> {
/// Error performing underlying HTTP request.
#[error("performing webdav request: {0}")]
RequestError(#[from] WebDavError<E>),
/// The `base_url` is not valid or could not be used to build a request URL.
///
/// Should not happen unless there is a bug in `hyper`.
#[error("cannot use base_url to build request uri: {0}")]
InvalidInput(#[from] http::Error),
}
/// Generic WebDAV client.
///
/// A WebDAV client that uses a parametrised http client `C` to perform the underlying HTTP
/// requests.
///
/// An existing http client that can be used is `hyper_util::client::legacy::Client`, although any
/// client which implements the trait bounds is acceptable. Essentially an http clients needs to
/// implement [`tower_service::Service`], taking a [`Request<Service>`] as input and returning a
/// [`Response<Incoming>`].
///
/// The provided http client can simply be one that wraps around an existing one.
/// These wrappers are called middleware in the Tower/Hyper ecosystem.
///
/// The most common and obvious example is one that adds an `Authorization` header to all outgoing
/// requests:
///
/// ```rust
/// # use libdav::dav::WebDavClient;
/// use http::Uri;
/// use hyper_rustls::HttpsConnectorBuilder;
/// use hyper_util::{client::legacy::Client, rt::TokioExecutor};
/// use tower_http::auth::AddAuthorization;
///
/// # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async {
/// let uri = Uri::try_from("https://example.com").unwrap();
///
/// let https_connector = HttpsConnectorBuilder::new()
/// .with_native_roots()
/// .unwrap()
/// .https_or_http()
/// .enable_http1()
/// .build();
/// let http_client = Client::builder(TokioExecutor::new()).build(https_connector);
/// let auth_client = AddAuthorization::basic(http_client, "user", "secret");
/// let webdav = WebDavClient::new(uri, auth_client);
/// # })
/// ```
///
/// The concrete type of the client in the above example is somewhat complex. For this reason,
/// application code will usually want to use an alias for the concrete type being used, and use
/// this alias through all types and functions that handle the WebDAV client:
///
/// ```rust
/// # use hyper_rustls::HttpsConnector;
/// # use hyper_util::client::legacy::{connect::HttpConnector, Client};
/// # use libdav::dav::WebDavClient;
/// # use tower_http::auth::AddAuthorization;
/// type MyClient = WebDavClient<AddAuthorization<Client<HttpsConnector<HttpConnector>, String>>>;
/// ```
///
/// # Setting a custom User-Agent header
///
/// The following example uses a custom middleware which sets a specific User-Agent on each
/// outgoing request:
///
/// ```rust
/// use std::task::{Context, Poll};
///
/// use hyper::{
/// header::{HeaderValue, USER_AGENT},
/// Request, Response,
/// };
/// use tower_service::Service;
///
/// #[derive(Debug, Clone)]
/// pub struct UserAgent<S> {
/// inner: S,
/// user_agent: HeaderValue,
/// }
///
/// impl<S> UserAgent<S> {
/// /// Add a custom User-Agent to outgoing requests.
/// pub fn new(inner: S, user_agent: HeaderValue) -> UserAgent<S> {
/// UserAgent { inner, user_agent }
/// }
/// }
///
/// impl<S, Tx, Rx> Service<Request<Tx>> for UserAgent<S>
/// where
/// S: Service<Request<Tx>, Response = Response<Rx>>,
/// {
/// type Response = S::Response;
/// type Error = S::Error;
/// type Future = S::Future;
///
/// fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
/// self.inner.poll_ready(cx)
/// }
///
/// fn call(&mut self, mut req: Request<Tx>) -> Self::Future {
/// req.headers_mut()
/// .insert(USER_AGENT, self.user_agent.clone());
/// self.inner.call(req)
/// }
/// }
///
/// // Elsewhere in your codebase...
/// # use libdav::dav::WebDavClient;
/// use http::Uri;
/// use hyper_rustls::HttpsConnectorBuilder;
/// use hyper_util::{client::legacy::Client, rt::TokioExecutor};
/// use tower_http::auth::AddAuthorization;
///
/// # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async {
/// let uri = Uri::try_from("https://example.com").unwrap();
///
/// let https_connector = HttpsConnectorBuilder::new()
/// .with_native_roots()
/// .unwrap()
/// .https_or_http()
/// .enable_http1()
/// .build();
/// let http_client = Client::builder(TokioExecutor::new()).build(https_connector);
/// let auth_client = UserAgent::new(http_client, "myapp/0.2.7".try_into().unwrap());
/// let webdav = WebDavClient::new(uri, auth_client);
/// # })
/// ```
///
/// For other generic middleware of this style, consult the [tower-http] crate.
///
/// [tower-http]: https://docs.rs/tower-http/
#[derive(Debug)]
pub struct WebDavClient<C>
where
C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + 'static,
{
/// Base URL to be used for all requests.
///
/// Composed of the domain+port used for the server, plus the context path where WebDAV
/// requests are served.
pub base_url: Uri,
http_client: Arc<Mutex<C>>,
}
impl<C> WebDavClient<C>
where
C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send,
<C as Service<http::Request<String>>>::Error: std::error::Error + Send + Sync,
{
/// Builds a new WebDAV client.
pub fn new(base_url: Uri, http_client: C) -> WebDavClient<C> {
WebDavClient {
base_url,
http_client: Arc::new(Mutex::from(http_client)),
}
}
/// Returns a URL pointing to the server's context path.
pub fn base_url(&self) -> &Uri {
&self.base_url
}
/// Returns a new URI relative to the server's root.
///
/// `path` MUST NOT be percent-encoded, except for any reserved characters.
///
/// # Errors
///
/// If this client's `base_url` is invalid or the provided `path` is not an acceptable path.
pub fn relative_uri(&self, path: &str) -> Result<Uri, http::Error> {
make_relative_url(self.base_url.clone(), path)
}
/// Resolves the current user's principal resource.
///
/// First queries the `base_url`, then the root path on the same host.
///
/// Returns `None` if the response's status code is 404 or if no principal was found.
///
/// # Errors
///
/// See [`FindCurrentUserPrincipalError`]
///
/// # See also
///
/// The `DAV:current-user-principal` property is defined in
/// <https://www.rfc-editor.org/rfc/rfc5397#section-3>
pub async fn find_current_user_principal(
&self,
) -> Result<Option<Uri>, FindCurrentUserPrincipalError<C::Error>> {
// Try querying the provided base url...
let result = self
.request(FindPropertyHrefs::new(
&self.base_url,
&names::CURRENT_USER_PRINCIPAL,
))
.await;
match result {
Ok(response) => {
if let Some(uri) = response.hrefs.into_iter().next() {
return Ok(Some(uri));
}
}
Err(WebDavError::BadStatusCode(StatusCode::NOT_FOUND)) => {}
Err(err) => return Err(FindCurrentUserPrincipalError::RequestError(err)),
}
debug!("User principal not found at base_url, trying root...");
// ... Otherwise, try querying the root path.
let root = self.relative_uri("/")?;
let response = self
.request(FindPropertyHrefs::new(
&root,
&names::CURRENT_USER_PRINCIPAL,
))
.await?;
Ok(response.hrefs.into_iter().next())
// NOTE: If no principal is resolved, it needs to be provided interactively
// by the user. We use `base_url` as a fallback.
}
/// Send a raw request to the server.
///
/// Sends a request, applying any necessary authentication and logging the response.
///
/// Lower-level API, prefer using [`WebDavClient::request`] with the Requests API instead.
///
/// # Errors
///
/// Returns an error if the underlying http request fails or if streaming the response fails.
pub async fn request_raw(
&self,
request: Request<String>,
) -> Result<(Parts, Bytes), RequestError<C::Error>> {
// QUIRK: When trying to fetch a resource on a URL that is a collection, iCloud
// will terminate the connection (which returns "unexpected end of file").
log::trace!(
"Sending {:?} request to {:?}, body={:?}, headers={:?}",
request.method(),
request.uri(),
request.body(),
request.headers()
);
let mut client = self.http_client.lock().await;
let response_future = client.call(request);
drop(client); // Unlock http_client.
let response = response_future.await.map_err(RequestError::Client)?;
let (head, body) = response.into_parts();
let body = body.collect().await?.to_bytes();
log::trace!("Response ({}): {:?}", head.status, body);
Ok((head, body))
}
/// Resolve the default context path using a well-known path.
///
/// Only applies for servers supporting WebDAV extensions like CalDAV or CardDAV. Returns
/// `Ok(None)` if the well-known path does not redirect to another location.
///
/// # Errors
///
/// - If the provided scheme, host and port cannot be used to construct a valid URL.
/// - If there are any network errors.
/// - If the response is not an HTTP redirection.
/// - If the `Location` header in the response is missing or invalid.
///
/// # See also
///
/// - <https://www.rfc-editor.org/rfc/rfc6764#section-5>
/// - [`ResolveContextPathError`]
#[allow(clippy::missing_panics_doc)] // panic condition is unreachable.
pub async fn find_context_path(
&self,
service: DiscoverableService,
host: &str,
port: u16,
) -> Result<Option<Uri>, ResolveContextPathError<C::Error>> {
let mut uri = Uri::builder()
.scheme(service.scheme())
.authority(format!("{host}:{port}"))
.path_and_query(service.well_known_path())
.build()?;
// Max 5 redirections.
for i in 0..5 {
let request = Request::builder()
.method(Method::GET)
.uri(&uri)
.body(String::new())?;
// From https://www.rfc-editor.org/rfc/rfc6764#section-5:
// > [...] the server MAY require authentication when a client tries to
// > access the ".well-known" URI
let (head, _body) = self.request_raw(request).await?;
log::debug!("Response finding context path: {}", head.status);
if !head.status.is_redirection() {
// If first request is not a redirect, no context path found.
// Otherwise, we've reached the final destination.
return Ok(if i == 0 { None } else { Some(uri) });
}
let location = head
.headers
.get(hyper::header::LOCATION)
.ok_or(ResolveContextPathError::MissingLocation)?
.as_bytes();
// TODO: Review percent-encoding; a header can contain spaces.
uri = Uri::try_from(location)?;
if uri.host().is_none() {
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()?);
}
uri = Uri::from_parts(parts).expect("uri parts are already validated");
}
}
Err(ResolveContextPathError::TooManyRedirects)
}
/// Execute a typed DAV request.
///
/// Provides a type-safe way to execute WebDAV operations using typed requests and response
/// types. Each request type implements the [`crate::requests::DavRequest`] trait, and can
/// therefore:
///
/// - Serialise itself into an HTTP request.
/// - Parse the HTTP response into a typed response.
///
/// # Example
///
/// ```
/// # use libdav::dav::WebDavClient;
/// # use libdav::dav::GetEtag;
/// # use tower_service::Service;
/// # async fn example<C>(webdav: &WebDavClient<C>) -> Result<(), Box<dyn std::error::Error>>
/// # where
/// # C: Service<http::Request<String>, Response = http::Response<hyper::body::Incoming>> + Send + Sync,
/// # C::Error: std::error::Error + Send + Sync,
/// # {
/// use libdav::dav::GetEtag;
///
/// let response = webdav.request(
/// GetEtag::new("/calendar/event.ics")
/// ).await?;
///
/// println!("Etag: {}", response.etag);
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// Returns an error if:
/// - The request cannot be prepared (e.g., invalid parameters).
/// - The HTTP request fails.
/// - The response cannot be parsed.
pub async fn request<R>(&self, request: R) -> Result<R::Response, R::Error<C::Error>>
where
R: DavRequest,
R::Error<C::Error>: From<http::Error>,
R::Error<C::Error>: From<RequestError<C::Error>>,
R::Error<C::Error>: From<R::ParseError>,
{
let prepared = request.prepare_request()?;
let mut http_request = Request::builder()
.method(prepared.method)
.uri(self.relative_uri(&prepared.path)?);
for (name, value) in prepared.headers {
http_request = http_request.header(name, value);
}
let http_request = http_request.body(prepared.body)?;
let (head, body) = self.request_raw(http_request).await?;
let response = request.parse_response(&head, &body)?;
Ok(response)
}
}
impl<C> Clone for WebDavClient<C>
where
C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + Clone,
{
fn clone(&self) -> WebDavClient<C> {
WebDavClient {
base_url: self.base_url.clone(),
http_client: self.http_client.clone(),
}
}
}
/// Make a new url using the schema and authority from `base` with the supplied `path`.
///
/// `path` MUST NOT be percent-encoded, except for any reserved characters.
///
/// # Errors
///
/// If this client's `base_url` is invalid or the provided `path` is not an acceptable path.
fn make_relative_url(base: Uri, path: &str) -> Result<Uri, http::Error> {
let path = strict_percent_encoded(path);
let mut parts = base.into_parts();
parts.path_and_query = Some(PathAndQuery::try_from(path.as_ref())?);
Uri::from_parts(parts).map_err(http::Error::from)
}
/// Checks if the status code is success. If it is not, return it as an error.
#[inline]
pub(crate) fn check_status(status: StatusCode) -> Result<(), StatusCode> {
if status.is_success() {
Ok(())
} else {
Err(status)
}
}
/// Mime-types commonly used with this library.
pub mod mime_types {
/// `text/calendar` mime-type.
pub const CALENDAR: &str = "text/calendar";
/// `text/vcard` mime-type.
pub const ADDRESSBOOK: &str = "text/vcard";
}
/// Metadata for a resource.
///
/// Returned when listing resources. It contains metadata on
/// resources but no the resource data itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedResource {
/// The path component to a collection.
///
/// Should be treated as an opaque string. Only reserved characters are percent-encoded.
pub href: String,
/// Status code for this resource, as returned by the server.
pub status: Option<StatusCode>,
/// The value of the `Content-Type` header, if any.
pub content_type: Option<String>,
/// The entity tag reflecting the version of the fetched resource.
///
/// See: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag>
pub etag: Option<String>,
/// DAV-specific resource type.
///
/// This field is subject to change.
pub resource_type: ResourceType,
}
/// Metadata for a collection.
///
/// Returned when listing collections. Contains metadata on collection itself,
/// but not the entries themselves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FoundCollection {
/// The path component to a collection.
///
/// Should be treated as an opaque string. Only reserved characters are percent-encoded.
pub href: String,
/// The entity tag reflecting the version of the fetched resource.
///
/// See: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag>
pub etag: Option<String>,
/// From: <https://www.rfc-editor.org/rfc/rfc6578>
pub supports_sync: bool,
// TODO: query displayname by default too.
}
pub(crate) fn extract_listed_resources(
body: &[u8],
collection_href: &str,
) -> Result<Vec<ListedResource>, ParseResponseError> {
let body = std::str::from_utf8(body)?;
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_normalised_href(&response)?.to_string();
// Don't list the collection itself.
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))
.as_deref()
.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 {
href,
status,
content_type,
etag,
resource_type,
});
}
Ok(items)
}
pub(crate) fn extract_fetched_resources(
body: &[u8],
property: &PropertyName<'_, '_>,
) -> Result<Vec<FetchedResource>, ParseResponseError> {
let body = std::str::from_utf8(body)?;
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(ParseResponseError::BadStatusCode(status)) => Some(status),
Err(e) => return Err(e),
};
let has_propstat = response // There MUST be zero or one propstat.
.descendants()
.any(|node| node.tag_name() == names::PROPSTAT);
if has_propstat {
let href = get_normalised_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() == names::GETETAG)
.ok_or(ParseResponseError::InvalidResponse(
"missing etag in response".into(),
))?
.text()
.ok_or(ParseResponseError::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().ok_or(ParseResponseError::InvalidResponse(
"missing text in href".into(),
))?;
let href = normalise_percent_encoded(href)?.to_string();
let status = status.ok_or(ParseResponseError::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;
use crate::{
FetchedResource, FetchedResourceContent, ResourceType,
dav::{ListedResource, extract_fetched_resources, extract_listed_resources},
names::{self, CALENDAR_DATA},
};
#[test]
fn 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 = extract_listed_resources(
raw,
"/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/",
)
.unwrap();
assert_eq!(results, vec![ListedResource {
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 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 = extract_fetched_resources(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 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 = extract_fetched_resources(raw, &CALENDAR_DATA).unwrap();
assert_eq!(
results,
vec![FetchedResource {
href: "/remote.php/dav/calendars/vdirsyncer/1678996875/".into(),
content: Err(StatusCode::NOT_FOUND)
}]
);
}
#[test]
fn 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 = extract_fetched_resources(b.as_bytes(), &names::CALENDAR_DATA).unwrap();
let content = resources.into_iter().next().unwrap().content.unwrap();
assert!(content.data.contains("ñoquis"));
assert!(content.data.contains("西红柿"));
}
/// See: <https://github.com/RazrFalcon/roxmltree/issues/108>
#[test]
fn 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 = extract_fetched_resources(b.as_bytes(), &names::CALENDAR_DATA).unwrap();
let content = resources.into_iter().next().unwrap().content.unwrap();
assert!(content.data.contains("baño"));
}
}