libdav/
dav.rs

1// Copyright 2023-2024 Hugo Osvaldo Barrera
2//
3// SPDX-License-Identifier: ISC
4
5//! Generic WebDAV implementation.
6//!
7//! This mostly implements the necessary bits for the CalDAV and CardDAV implementations. It should
8//! not be considered a general purpose WebDAV implementation.
9use std::string::FromUtf8Error;
10
11use http::{
12    response::Parts, status::InvalidStatusCode, uri::PathAndQuery, HeaderValue, Method, Request,
13    Response, StatusCode, Uri,
14};
15use http_body_util::BodyExt;
16use hyper::body::{Bytes, Incoming};
17use log::debug;
18use tokio::sync::Mutex;
19use tower_service::Service;
20
21use crate::{
22    encoding::{normalise_percent_encoded, strict_percent_encoded, NormalisationError},
23    names,
24    sd::DiscoverableService,
25    xmlutils::{
26        check_multistatus, get_newline_corrected_text, get_normalised_href, parse_statusline,
27        render_xml, render_xml_with_text,
28    },
29    Depth, FetchedResource, FetchedResourceContent, PropertyName, ResourceType,
30};
31
32/// Error executing an HTTP request.
33#[derive(thiserror::Error, Debug)]
34pub enum RequestError {
35    /// Error handling the HTTP stream.
36    #[error("error executing http request: {0}")]
37    Http(#[from] hyper::Error),
38
39    /// Error from the underlying HTTP client.
40    #[error("client error executing request: {0}")]
41    // TODO: remove dyn, make generic over client error type
42    Client(Box<dyn std::error::Error + Send + Sync>),
43}
44
45/// Error for WebDAV operations.
46#[derive(thiserror::Error, Debug)]
47#[allow(clippy::module_name_repetitions)]
48pub enum WebDavError {
49    /// Error performing underlying HTTP request.
50    #[error("http client error: {0}")]
51    Request(#[from] RequestError),
52
53    /// An expected field was missing in the HTTP response.
54    #[error("missing field '{0}' in response XML")]
55    MissingData(&'static str),
56
57    /// The server returned an invalid status code.
58    #[error("invalid status code in response: {0}")]
59    InvalidStatusCode(#[from] InvalidStatusCode),
60
61    /// Error parsing the XML response.
62    #[error("could not parse XML response: {0}")]
63    Xml(#[from] roxmltree::Error),
64
65    /// The server returned an unexpected status code.
66    #[error("http request returned {0}")]
67    BadStatusCode(http::StatusCode),
68
69    /// An argument passed to build a URL was invalid.
70    #[error("failed to build URL with the given input: {0}")]
71    InvalidInput(#[from] http::Error),
72
73    /// The Etag from the server is not a valid UTF-8 string.
74    #[error("the server returned an response with an invalid etag header: {0}")]
75    InvalidEtag(#[from] FromUtf8Error),
76
77    /// The server returned a response that did not contain valid data.
78    #[error("the server returned an invalid response: {0}")]
79    InvalidResponse(Box<dyn std::error::Error + Send + Sync>),
80
81    /// The response is not valid UTF-8.
82    ///
83    /// At this time, other encodings are not supported.
84    #[error("could not decode response as utf-8: {0}")]
85    NotUtf8(#[from] std::str::Utf8Error),
86}
87
88impl From<StatusCode> for WebDavError {
89    fn from(status: StatusCode) -> Self {
90        WebDavError::BadStatusCode(status)
91    }
92}
93
94impl From<NormalisationError> for WebDavError {
95    fn from(value: NormalisationError) -> Self {
96        WebDavError::InvalidResponse(value.into())
97    }
98}
99
100/// Error type for [`WebDavClient::find_context_path`].
101#[derive(thiserror::Error, Debug)]
102pub enum ResolveContextPathError {
103    /// An argument passed to build a URL was invalid.
104    #[error("failed to create uri and request with given parameters: {0}")]
105    BadInput(#[from] http::Error),
106
107    /// Error performing underlying HTTP request.
108    #[error("error performing http request: {0}")]
109    Request(#[from] RequestError),
110
111    /// The response is missing a required Location header.
112    #[error("missing Location header in response")]
113    MissingLocation,
114
115    /// The Location from the server's response could not be used to build a new URL.
116    #[error("error building new Uri with Location from response: {0}")]
117    BadLocation(#[from] http::uri::InvalidUri),
118}
119
120/// Error type for [`WebDavClient::find_current_user_principal`]
121#[derive(thiserror::Error, Debug)]
122pub enum FindCurrentUserPrincipalError {
123    /// Error performing underlying HTTP request.
124    #[error("error performing webdav request: {0}")]
125    RequestError(#[from] WebDavError),
126
127    /// The `base_url` is not valid or could not be used to build a request URL.
128    ///
129    /// This error should not happen unless there is a bug in `hyper`.
130    #[error("cannot use base_url to build request uri: {0}")]
131    InvalidInput(#[from] http::Error),
132}
133
134/// Generic WebDAV client.
135///
136/// A WebDAV client that uses a parametrised http client `C` to perform the underlying HTTP
137/// requests.
138///
139/// An existing http client that can be used is `hyper_util::client::legacy::Client`, although any
140/// client which implements the trait bounds is acceptable. Essentially an http clients needs to
141/// implement [`tower_service::Service`], taking a [`Request<Service>`] as input and returning a
142/// [`Response<Incoming>`].
143///
144/// This means that the provided http client can simply be one that wraps around an existing one.
145/// These wrappers are called middleware in the Tower/Hyper ecosystem.
146///
147/// The most common and obvious example is one that adds an `Authorization` header to all outgoing
148/// requests:
149///
150/// ```rust
151/// # use libdav::dav::WebDavClient;
152/// use http::Uri;
153/// use hyper_rustls::HttpsConnectorBuilder;
154/// use hyper_util::{client::legacy::Client, rt::TokioExecutor};
155/// use tower_http::auth::AddAuthorization;
156///
157/// # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async {
158/// let uri = Uri::try_from("https://example.com").unwrap();
159///
160/// let https_connector = HttpsConnectorBuilder::new()
161///     .with_native_roots()
162///     .unwrap()
163///     .https_or_http()
164///     .enable_http1()
165///     .build();
166/// let http_client = Client::builder(TokioExecutor::new()).build(https_connector);
167/// let auth_client = AddAuthorization::basic(http_client, "user", "secret");
168/// let webdav = WebDavClient::new(uri, auth_client);
169/// # })
170/// ```
171///
172/// The concrete type of the client in the above example is somewhat complex. For this reason,
173/// application code will usually want to use an alias for the concrete type being used, and use
174/// this alias through all types and functions that handle the WebDAV client:
175///
176/// ```rust
177/// # use hyper_rustls::HttpsConnector;
178/// # use hyper_util::client::legacy::{connect::HttpConnector, Client};
179/// # use libdav::dav::WebDavClient;
180/// # use tower_http::auth::AddAuthorization;
181/// type MyClient = WebDavClient<AddAuthorization<Client<HttpsConnector<HttpConnector>, String>>>;
182/// ```
183///
184/// # Setting a custom User-Agent header
185///
186/// The following example uses a custom middleware  which sets a specific User-Agent on each
187/// outgoing request:
188///
189/// ```rust
190/// use std::task::{Context, Poll};
191///
192/// use hyper::{
193///     header::{HeaderValue, USER_AGENT},
194///     Request, Response,
195/// };
196/// use tower_service::Service;
197///
198/// #[derive(Debug, Clone)]
199/// pub struct UserAgent<S> {
200///     inner: S,
201///     user_agent: HeaderValue,
202/// }
203///
204/// impl<S> UserAgent<S> {
205///     /// Add a custom User-Agent to outgoing requests.
206///     pub fn new(inner: S, user_agent: HeaderValue) -> UserAgent<S> {
207///         UserAgent { inner, user_agent }
208///     }
209/// }
210///
211/// impl<S, Tx, Rx> Service<Request<Tx>> for UserAgent<S>
212/// where
213///     S: Service<Request<Tx>, Response = Response<Rx>>,
214/// {
215///     type Response = S::Response;
216///     type Error = S::Error;
217///     type Future = S::Future;
218///
219///     fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
220///         self.inner.poll_ready(cx)
221///     }
222///
223///     fn call(&mut self, mut req: Request<Tx>) -> Self::Future {
224///         req.headers_mut()
225///             .insert(USER_AGENT, self.user_agent.clone());
226///         self.inner.call(req)
227///     }
228/// }
229///
230/// // Elsewhere in your codebase...
231/// # use libdav::dav::WebDavClient;
232/// use http::Uri;
233/// use hyper_rustls::HttpsConnectorBuilder;
234/// use hyper_util::{client::legacy::Client, rt::TokioExecutor};
235/// use tower_http::auth::AddAuthorization;
236///
237/// # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async {
238/// let uri = Uri::try_from("https://example.com").unwrap();
239///
240/// let https_connector = HttpsConnectorBuilder::new()
241///     .with_native_roots()
242///     .unwrap()
243///     .https_or_http()
244///     .enable_http1()
245///     .build();
246/// let http_client = Client::builder(TokioExecutor::new()).build(https_connector);
247/// let auth_client = UserAgent::new(http_client, "myapp/0.2.7".try_into().unwrap());
248/// let webdav = WebDavClient::new(uri, auth_client);
249/// # })
250/// ```
251///
252/// For other generic middleware of this style, consult the [tower-http] crate.
253///
254/// [tower-http]: https://docs.rs/tower-http/
255#[derive(Debug)]
256pub struct WebDavClient<C>
257where
258    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + 'static,
259{
260    /// Base URL to be used for all requests.
261    ///
262    /// This is composed of the domain+port used for the server, plus the context path where WebDAV
263    /// requests are served.
264    pub base_url: Uri,
265    http_client: Mutex<C>,
266}
267
268impl<C> WebDavClient<C>
269where
270    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send,
271    <C as Service<http::Request<String>>>::Error: std::error::Error + Send + Sync,
272{
273    /// Builds a new WebDAV client.
274    pub fn new(base_url: Uri, http_client: C) -> WebDavClient<C> {
275        WebDavClient {
276            base_url,
277            http_client: Mutex::from(http_client),
278        }
279    }
280
281    /// Returns a URL pointing to the server's context path.
282    pub fn base_url(&self) -> &Uri {
283        &self.base_url
284    }
285
286    /// Returns a new URI relative to the server's root.
287    ///
288    /// `path` MUST NOT be percent-encoded, except for any reserved characters.
289    ///
290    /// # Errors
291    ///
292    /// If this client's `base_url` is invalid or the provided `path` is not an acceptable path.
293    pub fn relative_uri(&self, path: &str) -> Result<Uri, http::Error> {
294        make_relative_url(self.base_url.clone(), path)
295    }
296
297    /// Resolves the current user's principal resource.
298    ///
299    /// First queries the `base_url`, then the root path on the same host.
300    ///
301    /// Returns `None` if the response's status code is 404 or if no principal was found.
302    ///
303    /// # Errors
304    ///
305    /// See [`FindCurrentUserPrincipalError`]
306    ///
307    /// # See also
308    ///
309    /// The `DAV:current-user-principal` property is defined in
310    /// <https://www.rfc-editor.org/rfc/rfc5397#section-3>
311    pub async fn find_current_user_principal(
312        &self,
313    ) -> Result<Option<Uri>, FindCurrentUserPrincipalError> {
314        // Try querying the provided base url...
315        let (head, body) = self
316            .propfind(
317                &self.base_url,
318                &[&names::CURRENT_USER_PRINCIPAL],
319                Depth::Zero,
320            )
321            .await?;
322        check_status(head.status).map_err(WebDavError::BadStatusCode)?;
323        let maybe_principal =
324            extract_principal_href(&body, &self.base_url, &names::CURRENT_USER_PRINCIPAL);
325
326        match maybe_principal {
327            Err(WebDavError::BadStatusCode(StatusCode::NOT_FOUND)) | Ok(None) => {}
328            Err(err) => return Err(FindCurrentUserPrincipalError::RequestError(err)),
329            Ok(Some(p)) => return Ok(Some(p)),
330        }
331        debug!("User principal not found at base_url, trying root...");
332
333        // ... Otherwise, try querying the root path.
334        let root = self.relative_uri("/")?;
335        let (head, body) = self
336            .propfind(&root, &[&names::CURRENT_USER_PRINCIPAL], Depth::Zero)
337            .await?;
338        check_status(head.status).map_err(WebDavError::BadStatusCode)?;
339        extract_principal_href(&body, &root, &names::CURRENT_USER_PRINCIPAL)
340            .map_err(FindCurrentUserPrincipalError::RequestError)
341
342        // NOTE: If no principal is resolved, it needs to be provided interactively
343        //       by the user. We use `base_url` as a fallback.
344    }
345
346    /// Internal helper for `find_XXX_home_sets` functions.
347    pub(crate) async fn find_home_sets(
348        &self,
349        url: &Uri,
350        property: &PropertyName<'_, '_>,
351    ) -> Result<Vec<Uri>, WebDavError> {
352        let (head, body) = self.propfind(url, &[property], Depth::Zero).await?;
353        check_status(head.status)?;
354        extract_home_set_href(&body, url, property)
355    }
356
357    /// Sends a `PROPFIND` request.
358    ///
359    /// Returns the head and body of the response.
360    ///
361    /// # Errors
362    ///
363    /// If there are any network errors or the provided `url` is invalid.
364    pub async fn propfind(
365        &self,
366        url: &Uri,
367        properties: &[&PropertyName<'_, '_>],
368        depth: Depth,
369    ) -> Result<(Parts, Bytes), WebDavError> {
370        let mut body = String::from(r#"<propfind xmlns="DAV:"><prop>"#);
371        for prop in properties {
372            body.push_str(&render_xml(prop));
373        }
374        body.push_str("</prop></propfind>");
375
376        let request = Request::builder()
377            .method("PROPFIND")
378            .uri(url)
379            .header("Content-Type", "application/xml; charset=utf-8")
380            .header("Depth", HeaderValue::from(depth))
381            .body(body)?;
382
383        self.request(request).await.map_err(WebDavError::from)
384    }
385
386    /// Send a request to the server.
387    ///
388    /// Sends a request, applying any necessary authentication and logging the response.
389    ///
390    /// # Errors
391    ///
392    /// Returns an error if the underlying http request fails or if streaming the response fails.
393    pub async fn request(&self, request: Request<String>) -> Result<(Parts, Bytes), RequestError> {
394        // QUIRK: When trying to fetch a resource on a URL that is a collection, iCloud
395        // will terminate the connection (which returns "unexpected end of file").
396
397        let mut client = self.http_client.lock().await;
398        let response_future = client.call(request);
399        drop(client); // E.g.: unlock http_client.
400
401        let response = response_future
402            .await
403            .map_err(|e| RequestError::Client(Box::from(e)))?;
404        let (head, body) = response.into_parts();
405        let body = body.collect().await?.to_bytes();
406
407        log::trace!("Response ({}): {:?}", head.status, body);
408        Ok((head, body))
409    }
410
411    /// Fetch a single property.
412    ///
413    /// # Common properties
414    ///
415    /// - [`names::ADDRESSBOOK_DESCRIPTION`]
416    /// - [`names::CALENDAR_COLOUR`]
417    /// - [`names::CALENDAR_DESCRIPTION`]
418    /// - [`names::CALENDAR_ORDER`]
419    /// - [`names::DISPLAY_NAME`]
420    ///
421    /// # Quirks
422    ///
423    /// The namespace of the value in the response from the server is ignored. This is a workaround
424    /// for an [issue in `cyrus-imapd`][cyrus-issue].
425    ///
426    /// [cyrus-issue]: https://github.com/cyrusimap/cyrus-imapd/issues/4489
427    ///
428    /// # Errors
429    ///
430    /// - If there are any network errors or the response could not be parsed.
431    /// - If the requested property is missing in the response.
432    ///
433    /// # See also
434    ///
435    /// - [`WebDavClient::set_property`]
436    pub async fn get_property(
437        &self,
438        href: &str,
439        property: &PropertyName<'_, '_>,
440    ) -> Result<Option<String>, WebDavError> {
441        let url = self.relative_uri(href)?;
442
443        let (head, body) = self.propfind(&url, &[property], Depth::Zero).await?;
444        check_status(head.status)?;
445
446        extract_one_prop(&body, property)
447    }
448
449    /// Fetch multiple properties for a single resource.
450    ///
451    /// Values in the returned `Vec` are in the same order as the `properties` parameter.
452    ///
453    /// # Quirks
454    ///
455    /// Same as [`WebDavClient::get_property`].
456    ///
457    /// # Errors
458    ///
459    /// - If there are any network errors or the response could not be parsed.
460    /// - If the requested property is missing in the response.
461    ///
462    /// # See also
463    ///
464    /// - [`WebDavClient::get_property`]
465    /// - [`WebDavClient::set_property`]
466    pub async fn get_properties<'ptr, 'p>(
467        &self,
468        href: &str,
469        properties: &[&'ptr PropertyName<'p, 'p>],
470    ) -> Result<Vec<(&'ptr PropertyName<'p, 'p>, Option<String>)>, WebDavError> {
471        let url = self.relative_uri(href)?;
472
473        let (head, body) = self.propfind(&url, properties, Depth::Zero).await?;
474        check_status(head.status)?;
475
476        let body = std::str::from_utf8(body.as_ref())?;
477        let doc = roxmltree::Document::parse(body)?;
478        let root = doc.root_element();
479
480        let mut results = Vec::with_capacity(properties.len());
481        for property in properties {
482            let prop = root
483                .descendants()
484                .find(|node| node.tag_name() == **property)
485                // Hack to work around: https://github.com/cyrusimap/cyrus-imapd/issues/4489
486                .or_else(|| {
487                    root.descendants()
488                        .find(|node| node.tag_name().name() == property.name())
489                })
490                // End hack
491                .and_then(|p| p.text())
492                .map(str::to_owned);
493
494            results.push((*property, prop));
495        }
496        Ok(results)
497    }
498
499    /// Sends a `PROPUPDATE` query to the server.
500    ///
501    /// Setting the value to `None` will remove the property. Returns the new value as returned by
502    /// the server. The provided value does not need to be escaped.
503    ///
504    /// # Quirks
505    ///
506    /// Same as [`WebDavClient::get_property`].
507    ///
508    /// # Errors
509    ///
510    /// If there are any network errors or the response could not be parsed.
511    ///
512    /// # See also
513    ///
514    /// - [`WebDavClient::get_property`] (contains a list of some included well-known properties)
515    pub async fn set_property(
516        &self,
517        href: &str,
518        property: &PropertyName<'_, '_>,
519        value: Option<&str>,
520    ) -> Result<Option<String>, WebDavError> {
521        let url = self.relative_uri(href)?;
522        let action = match value {
523            Some(_) => "set",
524            None => "remove",
525        };
526        let inner = render_xml_with_text(property, value);
527        let request = Request::builder()
528            .method("PROPPATCH")
529            .uri(url)
530            .header("Content-Type", "application/xml; charset=utf-8")
531            .body(format!(
532                r#"<propertyupdate xmlns="DAV:">
533                <{action}>
534                    <prop>
535                        {inner}
536                    </prop>
537                </{action}>
538            </propertyupdate>"#
539            ))?;
540
541        let (head, body) = self.request(request).await?;
542        check_status(head.status)?;
543
544        extract_one_prop(&body, property)
545    }
546
547    /// Resolve the default context path using a well-known path.
548    ///
549    /// This only applies for servers supporting WebDAV extensions like CalDAV or CardDAV. Returns
550    /// `Ok(None)` if the well-known path does not redirect to another location.
551    ///
552    /// # Errors
553    ///
554    /// - If the provided scheme, host and port cannot be used to construct a valid URL.
555    /// - If there are any network errors.
556    /// - If the response is not an HTTP redirection.
557    /// - If the `Location` header in the response is missing or invalid.
558    ///
559    /// # See also
560    ///
561    /// - <https://www.rfc-editor.org/rfc/rfc6764#section-5>
562    /// - [`ResolveContextPathError`]
563    #[allow(clippy::missing_panics_doc)] // panic condition is unreachable.
564    pub async fn find_context_path(
565        &self,
566        service: DiscoverableService,
567        host: &str,
568        port: u16,
569    ) -> Result<Option<Uri>, ResolveContextPathError> {
570        let uri = Uri::builder()
571            .scheme(service.scheme())
572            .authority(format!("{host}:{port}"))
573            .path_and_query(service.well_known_path())
574            .build()?;
575
576        let request = Request::builder()
577            .method(Method::GET)
578            .uri(uri)
579            .body(String::new())?;
580
581        // From https://www.rfc-editor.org/rfc/rfc6764#section-5:
582        // > [...] the server MAY require authentication when a client tries to
583        // > access the ".well-known" URI
584        let (head, _body) = self.request(request).await?;
585        log::debug!("Response finding context path: {}", head.status);
586
587        if !head.status.is_redirection() {
588            return Ok(None);
589        }
590
591        // TODO: multiple redirections...?
592        let location = head
593            .headers
594            .get(hyper::header::LOCATION)
595            .ok_or(ResolveContextPathError::MissingLocation)?
596            .as_bytes();
597        // TODO: Review percent-encoding; a header can contain spaces.
598        let uri = Uri::try_from(location)?;
599
600        if uri.host().is_some() {
601            return Ok(Some(uri)); // Uri is absolute.
602        }
603
604        let mut parts = uri.into_parts();
605        if parts.scheme.is_none() {
606            parts.scheme = Some(service.scheme());
607        }
608        if parts.authority.is_none() {
609            parts.authority = Some(format!("{host}:{port}").try_into()?);
610        }
611
612        let uri = Uri::from_parts(parts).expect("uri parts are already validated");
613        Ok(Some(uri))
614    }
615
616    /// Enumerates resources in a collection
617    ///
618    /// # Errors
619    ///
620    /// If there are any network errors or the response could not be parsed.
621    pub async fn list_resources(
622        &self,
623        collection_href: &str,
624    ) -> Result<Vec<ListedResource>, WebDavError> {
625        let url = self.relative_uri(collection_href)?;
626
627        let (head, body) = self
628            .propfind(
629                &url,
630                &[
631                    &names::RESOURCETYPE,
632                    &names::GETCONTENTTYPE,
633                    &names::GETETAG,
634                ],
635                Depth::One,
636            )
637            .await?;
638        check_status(head.status)?;
639
640        extract_listed_resources(&body, collection_href)
641    }
642
643    /// Inner helper with common logic between `create` and `update`.
644    async fn put(
645        &self,
646        href: &str,
647        data: Vec<u8>,
648        etag: Option<&str>,
649        mime_type: &[u8],
650    ) -> Result<Option<String>, WebDavError> {
651        let mut builder = Request::builder()
652            .method(Method::PUT)
653            .uri(self.relative_uri(href)?)
654            .header("Content-Type", mime_type.as_ref());
655
656        builder = match etag {
657            Some(etag) => builder.header("If-Match", etag),
658            None => builder.header("If-None-Match", "*"),
659        };
660
661        let request = String::from_utf8(data)
662            .map_err(|e| WebDavError::NotUtf8(e.utf8_error()))
663            .map(|string| builder.body(string))??;
664
665        let (head, _body) = self.request(request).await?;
666        check_status(head.status)?;
667
668        // TODO: check multi-response
669
670        let new_etag = head
671            .headers
672            .get("etag")
673            .map(|hv| String::from_utf8(hv.as_bytes().to_vec()))
674            .transpose()?;
675        Ok(new_etag)
676    }
677
678    /// Creates a new resource
679    ///
680    /// Returns an `Etag` if present in the response. If the `Etag` is not included, it must be
681    /// requested in a follow-up request, and cannot be obtained race-free.
682    ///
683    /// # Errors
684    ///
685    /// If there are any network errors or the response could not be parsed.
686    pub async fn create_resource(
687        &self,
688        href: &str,
689        data: Vec<u8>,
690        mime_type: &[u8],
691    ) -> Result<Option<String>, WebDavError> {
692        self.put(href, data, Option::<&str>::None, mime_type).await
693    }
694
695    /// Updates an existing resource
696    ///
697    /// Returns an `Etag` if present in the response. If the `Etag` is not included, it must be
698    /// requested in a follow-up request, and cannot be obtained race-free.
699    ///
700    /// # Errors
701    ///
702    /// If there are any network errors or the response could not be parsed.
703    pub async fn update_resource(
704        &self,
705        href: &str,
706        data: Vec<u8>,
707        etag: &str,
708        mime_type: &[u8],
709    ) -> Result<Option<String>, WebDavError> {
710        self.put(href, data, Some(etag), mime_type).await
711    }
712
713    /// Creates a collection under path `href`.
714    ///
715    /// This function executes an [Extended MKCOL](https://www.rfc-editor.org/rfc/rfc5689).
716    ///
717    /// Additional resource types may be specified via the `resourcetypes` argument. The
718    /// `DAV:collection` resource type is implied and MUST NOT be specified.
719    ///
720    /// # Errors
721    ///
722    /// If there are any network errors or the response could not be parsed.
723    pub async fn create_collection(
724        &self,
725        href: &str,
726        resourcetypes: &[&PropertyName<'_, '_>],
727    ) -> Result<(), WebDavError> {
728        let mut rendered_resource_types = String::new();
729        for resource_type in resourcetypes {
730            rendered_resource_types.push_str(&render_xml(resource_type));
731        }
732
733        let body = format!(
734            r#"
735            <mkcol xmlns="DAV:">
736                <set>
737                    <prop>
738                        <resourcetype>
739                            <collection/>
740                            {rendered_resource_types}
741                        </resourcetype>
742                    </prop>
743                </set>
744            </mkcol>"#
745        );
746
747        let request = Request::builder()
748            .method("MKCOL")
749            .uri(self.relative_uri(href.as_ref())?)
750            .header("Content-Type", "application/xml; charset=utf-8")
751            .body(body)?;
752
753        let (head, _body) = self.request(request).await?;
754        // TODO: we should check the response body here, if present.
755        // Some servers (e.g.: Fastmail) return an empty body.
756        check_status(head.status)?;
757
758        Ok(())
759    }
760
761    /// Deletes the resource at `href`.
762    ///
763    /// The resource MAY be a collection. Because the implementation for deleting resources and
764    /// collections is identical, this same function is used for both cases.
765    ///
766    /// If the Etag does not match (i.e.: if the resource has been altered), the operation will
767    /// fail and return an Error.
768    ///
769    /// # Errors
770    ///
771    /// If there are any network errors or the response could not be parsed.
772    // TODO: document WHICH error is returned on Etag mismatch.
773    pub async fn delete(&self, href: &str, etag: &str) -> Result<(), WebDavError> {
774        let request = Request::builder()
775            .method(Method::DELETE)
776            .uri(self.relative_uri(href.as_ref())?)
777            .header("Content-Type", "application/xml; charset=utf-8")
778            .header("If-Match", etag)
779            .body(String::new())?;
780
781        let (head, _body) = self.request(request).await?;
782
783        check_status(head.status).map_err(WebDavError::BadStatusCode)
784    }
785
786    /// Force deletion of the resource at `href`.
787    ///
788    /// This function does not guarantee that a resource or collection has not been modified since
789    /// it was last read. **Use this function with care**.
790    ///
791    /// The resource MAY be a collection. Because the implementation for deleting resources and
792    /// collections is identical, this same function covers both cases.
793    ///
794    /// # Errors
795    ///
796    /// If there are any network errors or the response could not be parsed.
797    pub async fn force_delete(&self, href: &str) -> Result<(), WebDavError> {
798        let request = Request::builder()
799            .method(Method::DELETE)
800            .uri(self.relative_uri(href.as_ref())?)
801            .header("Content-Type", "application/xml; charset=utf-8")
802            .body(String::new())?;
803
804        let (head, _body) = self.request(request).await?;
805
806        check_status(head.status).map_err(WebDavError::BadStatusCode)
807    }
808
809    pub(crate) async fn multi_get(
810        &self,
811        collection_href: &str,
812        body: String,
813        property: &PropertyName<'_, '_>,
814    ) -> Result<Vec<FetchedResource>, WebDavError> {
815        let request = Request::builder()
816            .method("REPORT")
817            .uri(self.relative_uri(collection_href)?)
818            .header("Content-Type", "application/xml; charset=utf-8")
819            .body(body)?;
820
821        let (head, body) = self.request(request).await?;
822        check_status(head.status)?;
823
824        extract_fetched_resources(&body, property)
825    }
826}
827
828/// Make a new url using the schema and authority from `base` with the supplied `path`.
829///
830/// `path` MUST NOT be percent-encoded, except for any reserved characters.
831///
832/// # Errors
833///
834/// If this client's `base_url` is invalid or the provided `path` is not an acceptable path.
835fn make_relative_url(base: Uri, path: &str) -> Result<Uri, http::Error> {
836    let path = strict_percent_encoded(path);
837    let mut parts = base.into_parts();
838    parts.path_and_query = Some(PathAndQuery::try_from(path.as_ref())?);
839    Uri::from_parts(parts).map_err(http::Error::from)
840}
841
842/// Checks if the status code is success. If it is not, return it as an error.
843#[inline]
844pub(crate) fn check_status(status: StatusCode) -> Result<(), StatusCode> {
845    if status.is_success() {
846        Ok(())
847    } else {
848        Err(status)
849    }
850}
851
852/// Mime-types commonly used with this library.
853pub mod mime_types {
854    /// `text/calendar` mime-type.
855    pub const CALENDAR: &[u8] = b"text/calendar";
856    /// `text/vcard` mime-type.
857    pub const ADDRESSBOOK: &[u8] = b"text/vcard";
858}
859
860/// Metadata for a resource.
861///
862/// This type is returned when listing resources. It contains metadata on
863/// resources but no the resource data itself.
864#[derive(Debug, PartialEq)]
865pub struct ListedResource {
866    /// The path component to a collection.
867    ///
868    /// Should be treated as an opaque string. Only reserved characters are percent-encoded.
869    pub href: String,
870    /// Status code for this resource, as returned by the server.
871    pub status: Option<StatusCode>,
872    /// The value of the `Content-Type` header, if any.
873    pub content_type: Option<String>,
874    /// The entity tag reflecting the version of the fetched resource.
875    ///
876    /// See: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag>
877    pub etag: Option<String>,
878    /// DAV-specific resource type.
879    ///
880    /// This field is subject to change.
881    pub resource_type: ResourceType,
882}
883
884/// Metadata for a collection.
885///
886/// This type is returned when listing collections. It contains metadata on
887/// collection itself, but not the entires themselves.
888#[derive(Debug)]
889pub struct FoundCollection {
890    /// The path component to a collection.
891    ///
892    /// Should be treated as an opaque string. Only reserved characters are percent-encoded.
893    pub href: String,
894    /// The entity tag reflecting the version of the fetched resource.
895    ///
896    /// See: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag>
897    pub etag: Option<String>,
898    /// From: <https://www.rfc-editor.org/rfc/rfc6578>
899    pub supports_sync: bool,
900    // TODO: query displayname by default too.
901}
902
903/// Extract an URL from a multi-response.
904///
905/// Extract the `href` property from a single property tag matching `property`.
906/// Returned URLs are made relative to the provided URL.
907pub(crate) fn extract_principal_href(
908    body: &[u8],
909    url: &Uri,
910    property: &PropertyName<'_, '_>,
911) -> Result<Option<Uri>, WebDavError> {
912    let body = std::str::from_utf8(body)?;
913    let doc = roxmltree::Document::parse(body)?;
914    let root = doc.root_element();
915
916    let mut props = root
917        .descendants()
918        .filter(|node| node.tag_name() == *property);
919
920    let Some(prop) = props.next() else {
921        // Property might be undefined and missing.
922        return Ok(None);
923    };
924    if props.next().is_some() {
925        return Err(WebDavError::InvalidResponse(
926            "found multiple href nodes; expected one".into(),
927        ));
928    }
929
930    if let Some(href_node) = prop.children().find(|node| node.tag_name() == names::HREF) {
931        if let Some(href) = href_node.text() {
932            let href = normalise_percent_encoded(href)?;
933            let href = make_relative_url(url.clone(), &href)
934                .map_err(|e| WebDavError::InvalidResponse(Box::from(e)))?;
935            return Ok(Some(href));
936        }
937        return Ok(None);
938    }
939
940    check_multistatus(root)?;
941
942    Err(WebDavError::InvalidResponse(
943        "missing property in response but no error".into(),
944    ))
945}
946
947fn extract_one_prop(
948    body: &[u8],
949    property: &PropertyName<'_, '_>,
950) -> Result<Option<String>, WebDavError> {
951    let body = std::str::from_utf8(body)?;
952    let doc = roxmltree::Document::parse(body)?;
953    let root = doc.root_element();
954
955    let prop = root.descendants().find(|node| node.tag_name() == *property);
956
957    if let Some(prop) = prop {
958        return Ok(prop.text().map(str::to_string));
959    }
960
961    check_multistatus(root)?;
962
963    Err(WebDavError::InvalidResponse(
964        "Property is missing from response, but response is non-error.".into(),
965    ))
966}
967
968pub(crate) fn extract_multi_prop(
969    body: &[u8],
970    property: &PropertyName<'_, '_>,
971) -> Result<Vec<String>, WebDavError> {
972    let body = std::str::from_utf8(body)?;
973    let doc = roxmltree::Document::parse(body)?;
974    let root = doc.root_element();
975
976    let prop = root.descendants().find(|node| node.tag_name() == *property);
977
978    if let Some(prop) = prop {
979        let values = prop
980            .descendants()
981            .filter(|node| node.tag_name() == names::HREF)
982            .map(|h| h.text().map(str::to_string))
983            .collect::<Option<Vec<_>>>()
984            .ok_or(WebDavError::InvalidResponse(
985                "DAV:href in response is missing a text".into(),
986            ))?;
987        return Ok(values);
988    }
989
990    check_multistatus(root)?;
991
992    Err(WebDavError::InvalidResponse(
993        "Property is missing from response, but response is non-error.".into(),
994    ))
995}
996
997fn extract_listed_resources(
998    body: &[u8],
999    collection_href: &str,
1000) -> Result<Vec<ListedResource>, WebDavError> {
1001    let body = std::str::from_utf8(body)?;
1002    let doc = roxmltree::Document::parse(body)?;
1003    let root = doc.root_element();
1004    let responses = root
1005        .descendants()
1006        .filter(|node| node.tag_name() == names::RESPONSE);
1007
1008    let mut items = Vec::new();
1009    for response in responses {
1010        let href = get_normalised_href(&response)?.to_string();
1011
1012        // Don't list the collection itself.
1013        if href == collection_href {
1014            continue;
1015        }
1016
1017        let status = response
1018            .descendants()
1019            .find(|node| node.tag_name() == names::STATUS)
1020            .and_then(|node| node.text().map(str::to_string))
1021            .as_deref()
1022            .map(parse_statusline)
1023            .transpose()?;
1024        let etag = response
1025            .descendants()
1026            .find(|node| node.tag_name() == names::GETETAG)
1027            .and_then(|node| node.text().map(str::to_string));
1028        let content_type = response
1029            .descendants()
1030            .find(|node| node.tag_name() == names::GETCONTENTTYPE)
1031            .and_then(|node| node.text().map(str::to_string));
1032        let resource_type = if let Some(r) = response
1033            .descendants()
1034            .find(|node| node.tag_name() == names::RESOURCETYPE)
1035        {
1036            ResourceType {
1037                is_calendar: r.descendants().any(|n| n.tag_name() == names::CALENDAR),
1038                is_collection: r.descendants().any(|n| n.tag_name() == names::COLLECTION),
1039                is_address_book: r.descendants().any(|n| n.tag_name() == names::ADDRESSBOOK),
1040            }
1041        } else {
1042            ResourceType::default()
1043        };
1044
1045        items.push(ListedResource {
1046            href,
1047            status,
1048            content_type,
1049            etag,
1050            resource_type,
1051        });
1052    }
1053
1054    Ok(items)
1055}
1056
1057fn extract_fetched_resources(
1058    body: &[u8],
1059    property: &PropertyName<'_, '_>,
1060) -> Result<Vec<FetchedResource>, WebDavError> {
1061    let body = std::str::from_utf8(body)?;
1062    let doc = roxmltree::Document::parse(body)?;
1063    let responses = doc
1064        .root_element()
1065        .descendants()
1066        .filter(|node| node.tag_name() == names::RESPONSE);
1067
1068    let mut items = Vec::new();
1069    for response in responses {
1070        let status = match check_multistatus(response) {
1071            Ok(()) => None,
1072            Err(WebDavError::BadStatusCode(status)) => Some(status),
1073            Err(e) => return Err(e),
1074        };
1075
1076        let has_propstat = response // There MUST be zero or one propstat.
1077            .descendants()
1078            .any(|node| node.tag_name() == names::PROPSTAT);
1079
1080        if has_propstat {
1081            let href = get_normalised_href(&response)?.to_string();
1082
1083            if let Some(status) = status {
1084                items.push(FetchedResource {
1085                    href,
1086                    content: Err(status),
1087                });
1088                continue;
1089            }
1090
1091            let etag = response
1092                .descendants()
1093                .find(|node| node.tag_name() == crate::names::GETETAG)
1094                .ok_or(WebDavError::InvalidResponse(
1095                    "missing etag in response".into(),
1096                ))?
1097                .text()
1098                .ok_or(WebDavError::InvalidResponse("missing text in etag".into()))?
1099                .to_string();
1100            let data = get_newline_corrected_text(&response, property)?;
1101
1102            items.push(FetchedResource {
1103                href,
1104                content: Ok(FetchedResourceContent { data, etag }),
1105            });
1106        } else {
1107            let hrefs = response
1108                .descendants()
1109                .filter(|node| node.tag_name() == names::HREF);
1110
1111            for href in hrefs {
1112                let href = href
1113                    .text()
1114                    .ok_or(WebDavError::InvalidResponse("missing text in href".into()))?;
1115                let href = normalise_percent_encoded(href)?.to_string();
1116                let status = status.ok_or(WebDavError::InvalidResponse(
1117                    "missing props but no error status code".into(),
1118                ))?;
1119                items.push(FetchedResource {
1120                    href,
1121                    content: Err(status),
1122                });
1123            }
1124        }
1125    }
1126
1127    Ok(items)
1128}
1129
1130/// Extract URLs from a multi-response.
1131///
1132/// Extract the `href` property from each property tag matching `property`.
1133/// Returned URLs are made relative to the provided URL.
1134fn extract_home_set_href(
1135    body: &[u8],
1136    url: &Uri,
1137    property: &PropertyName<'_, '_>,
1138) -> Result<Vec<Uri>, WebDavError> {
1139    let body = std::str::from_utf8(body)?;
1140    let doc = roxmltree::Document::parse(body)?;
1141    let root = doc.root_element();
1142
1143    let props = root
1144        .descendants()
1145        .filter(|node| node.tag_name() == *property)
1146        .collect::<Vec<_>>();
1147
1148    if props.len() == 1 {
1149        let mut hrefs = Vec::new();
1150
1151        let href_nodes = props[0]
1152            .children()
1153            .filter(|node| node.tag_name() == names::HREF);
1154
1155        for href_node in href_nodes {
1156            if let Some(href) = href_node.text() {
1157                let href = normalise_percent_encoded(href)?;
1158                let url = make_relative_url(url.clone(), &href)
1159                    .map_err(|e| WebDavError::InvalidResponse(Box::from(e)))?;
1160                hrefs.push(url);
1161            }
1162        }
1163
1164        return Ok(hrefs);
1165    }
1166
1167    check_multistatus(root)?;
1168
1169    Err(WebDavError::InvalidResponse(
1170        "missing property in response but no error".into(),
1171    ))
1172}
1173
1174#[cfg(test)]
1175mod more_tests {
1176
1177    use http::{StatusCode, Uri};
1178
1179    use crate::{
1180        dav::{
1181            extract_fetched_resources, extract_listed_resources, extract_multi_prop,
1182            extract_one_prop, extract_principal_href, ListedResource,
1183        },
1184        names::{self, CALENDAR_COLOUR, CALENDAR_DATA, CURRENT_USER_PRINCIPAL, DISPLAY_NAME},
1185        FetchedResource, FetchedResourceContent, ResourceType,
1186    };
1187
1188    use super::extract_home_set_href;
1189
1190    #[test]
1191    fn multi_get_parse() {
1192        let raw = br#"
1193<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
1194  <response>
1195    <href>/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/</href>
1196    <propstat>
1197      <prop>
1198        <resourcetype>
1199          <collection/>
1200          <C:calendar/>
1201        </resourcetype>
1202        <getcontenttype>text/calendar; charset=utf-8</getcontenttype>
1203        <getetag>"1591712486-1-1"</getetag>
1204      </prop>
1205      <status>HTTP/1.1 200 OK</status>
1206    </propstat>
1207  </response>
1208  <response>
1209    <href>/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/395b00a0-eebc-40fd-a98e-176a06367c82.ics</href>
1210    <propstat>
1211      <prop>
1212        <resourcetype/>
1213        <getcontenttype>text/calendar; charset=utf-8; component=VEVENT</getcontenttype>
1214        <getetag>"e7577ff2b0924fe8e9a91d3fb2eb9072598bf9fb"</getetag>
1215      </prop>
1216      <status>HTTP/1.1 200 OK</status>
1217    </propstat>
1218  </response>
1219</multistatus>"#;
1220
1221        let results = extract_listed_resources(
1222            raw,
1223            "/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/",
1224        )
1225        .unwrap();
1226
1227        assert_eq!(results, vec![ListedResource {
1228            content_type: Some("text/calendar; charset=utf-8; component=VEVENT".into()),
1229            etag: Some("\"e7577ff2b0924fe8e9a91d3fb2eb9072598bf9fb\"".into()),
1230            resource_type: ResourceType {
1231                is_collection: false,
1232                is_calendar: false,
1233                is_address_book: false
1234            },
1235            href: "/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/395b00a0-eebc-40fd-a98e-176a06367c82.ics".into(),
1236            status: Some(StatusCode::OK),
1237        }]);
1238    }
1239
1240    #[test]
1241    #[should_panic(expected = "assertion `left == right` failed")] // FIXME: needs review; broken.
1242    fn list_resources_parse_404() {
1243        // As returned by Xandikos (over a Unix socket):
1244        let raw = br#"
1245<ns0:multistatus xmlns:ns0="DAV:">
1246    <ns0:response>
1247        <ns0:href>http%3A//2f746d702f736f636b6574/user/contacts/Default</ns0:href>
1248        <ns0:status>HTTP/1.1 404 Not Found</ns0:status>
1249    </ns0:response>
1250</ns0:multistatus>
1251"#;
1252
1253        let results = extract_listed_resources(raw, "/user/contacts/Default").unwrap();
1254
1255        assert_eq!(
1256            results,
1257            vec![ListedResource {
1258                content_type: None,
1259                etag: None,
1260                resource_type: ResourceType {
1261                    is_collection: false,
1262                    is_calendar: false,
1263                    is_address_book: false
1264                },
1265                href: "http://2f746d702f736f636b6574/user/contacts/Default".to_string(),
1266                status: Some(StatusCode::NOT_FOUND),
1267            }]
1268        );
1269    }
1270
1271    #[test]
1272    fn multi_get_parse_with_err() {
1273        let raw = br#"
1274<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:caldav">
1275  <ns0:response>
1276    <ns0:href>/user/calendars/Q208cKvMGjAdJFUw/qJJ9Li5DPJYr.ics</ns0:href>
1277    <ns0:propstat>
1278      <ns0:status>HTTP/1.1 200 OK</ns0:status>
1279      <ns0:prop>
1280        <ns0:getetag>"adb2da8d3cb1280a932ed8f8a2e8b4ecf66d6a02"</ns0:getetag>
1281        <ns1:calendar-data>CALENDAR-DATA-HERE</ns1:calendar-data>
1282      </ns0:prop>
1283    </ns0:propstat>
1284  </ns0:response>
1285  <ns0:response>
1286    <ns0:href>/user/calendars/Q208cKvMGjAdJFUw/rKbu4uUn.ics</ns0:href>
1287    <ns0:status>HTTP/1.1 404 Not Found</ns0:status>
1288  </ns0:response>
1289</ns0:multistatus>
1290"#;
1291
1292        let results = extract_fetched_resources(raw, &CALENDAR_DATA).unwrap();
1293
1294        assert_eq!(
1295            results,
1296            vec![
1297                FetchedResource {
1298                    href: "/user/calendars/Q208cKvMGjAdJFUw/qJJ9Li5DPJYr.ics".into(),
1299                    content: Ok(FetchedResourceContent {
1300                        data: "CALENDAR-DATA-HERE".into(),
1301                        etag: "\"adb2da8d3cb1280a932ed8f8a2e8b4ecf66d6a02\"".into(),
1302                    })
1303                },
1304                FetchedResource {
1305                    href: "/user/calendars/Q208cKvMGjAdJFUw/rKbu4uUn.ics".into(),
1306                    content: Err(StatusCode::NOT_FOUND)
1307                }
1308            ]
1309        );
1310    }
1311
1312    #[test]
1313    fn multi_get_parse_mixed() {
1314        let raw = br#"
1315<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
1316    <d:response>
1317        <d:href>/remote.php/dav/calendars/vdirsyncer/1678996875/</d:href>
1318        <d:propstat>
1319            <d:prop>
1320                <d:resourcetype>
1321                    <d:collection/>
1322                    <cal:calendar/>
1323                </d:resourcetype>
1324            </d:prop>
1325            <d:status>HTTP/1.1 200 OK</d:status>
1326        </d:propstat>
1327        <d:propstat>
1328            <d:prop>
1329                <d:getetag/>
1330            </d:prop>
1331            <d:status>HTTP/1.1 404 Not Found</d:status>
1332        </d:propstat>
1333    </d:response>
1334</d:multistatus>"#;
1335
1336        let results = extract_fetched_resources(raw, &CALENDAR_DATA).unwrap();
1337
1338        assert_eq!(
1339            results,
1340            vec![FetchedResource {
1341                href: "/remote.php/dav/calendars/vdirsyncer/1678996875/".into(),
1342                content: Err(StatusCode::NOT_FOUND)
1343            }]
1344        );
1345    }
1346
1347    #[test]
1348    fn multi_get_parse_encoding() {
1349        let b = r#"<?xml version="1.0" encoding="utf-8"?>
1350<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1351  <response>
1352    <href>/dav/calendars/user/hugo@whynothugo.nl/2100F960-2655-4E75-870F-CAA793466105/0F276A13-FBF3-49A1-8369-65EEA9C6F891.ics</href>
1353    <propstat>
1354      <prop>
1355        <getetag>"4219b87012f42ce7c4db55599aa3b579c70d8795"</getetag>
1356        <C:calendar-data><![CDATA[BEGIN:VCALENDAR
1357CALSCALE:GREGORIAN
1358PRODID:-//Apple Inc.//iOS 17.0//EN
1359VERSION:2.0
1360BEGIN:VTODO
1361COMPLETED:20230425T155913Z
1362CREATED:20210622T182718Z
1363DTSTAMP:20230915T132714Z
1364LAST-MODIFIED:20230425T155913Z
1365PERCENT-COMPLETE:100
1366SEQUENCE:1
1367STATUS:COMPLETED
1368SUMMARY:Comidas: ñoquis, 西红柿
1369UID:0F276A13-FBF3-49A1-8369-65EEA9C6F891
1370X-APPLE-SORT-ORDER:28
1371END:VTODO
1372END:VCALENDAR
1373]]></C:calendar-data>
1374      </prop>
1375      <status>HTTP/1.1 200 OK</status>
1376    </propstat>
1377  </response>
1378</multistatus>"#;
1379
1380        let resources = extract_fetched_resources(b.as_bytes(), &names::CALENDAR_DATA).unwrap();
1381        let content = resources.into_iter().next().unwrap().content.unwrap();
1382        assert!(content.data.contains("ñoquis"));
1383        assert!(content.data.contains("西红柿"));
1384    }
1385
1386    /// See: <https://github.com/RazrFalcon/roxmltree/issues/108>
1387    #[test]
1388    fn multi_get_parse_encoding_another() {
1389        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";
1390        let resources = extract_fetched_resources(b.as_bytes(), &names::CALENDAR_DATA).unwrap();
1391        let content = resources.into_iter().next().unwrap().content.unwrap();
1392        assert!(content.data.contains("baño"));
1393    }
1394
1395    #[test]
1396    fn parse_prop_href() {
1397        let raw = br#"
1398<multistatus xmlns="DAV:">
1399  <response>
1400    <href>/dav/calendars</href>
1401    <propstat>
1402      <prop>
1403        <current-user-principal>
1404          <href>/dav/principals/user/vdirsyncer@example.com/</href>
1405        </current-user-principal>
1406      </prop>
1407      <status>HTTP/1.1 200 OK</status>
1408    </propstat>
1409  </response>
1410</multistatus>"#;
1411
1412        let results = extract_principal_href(
1413            raw,
1414            &Uri::try_from("https://example.com/").unwrap(),
1415            &CURRENT_USER_PRINCIPAL,
1416        )
1417        .unwrap();
1418
1419        assert_eq!(
1420            results,
1421            Some(
1422                Uri::try_from("https://example.com/dav/principals/user/vdirsyncer@example.com/")
1423                    .unwrap()
1424            )
1425        );
1426    }
1427
1428    #[test]
1429    fn parse_prop_cdata() {
1430        let raw = br#"
1431            <multistatus xmlns="DAV:">
1432                <response>
1433                    <href>/path</href>
1434                    <propstat>
1435                        <prop>
1436                            <displayname><![CDATA[test calendar]]></displayname>
1437                        </prop>
1438                        <status>HTTP/1.1 200 OK</status>
1439                    </propstat>
1440                </response>
1441            </multistatus>
1442            "#;
1443
1444        let results = extract_one_prop(raw, &DISPLAY_NAME).unwrap();
1445
1446        assert_eq!(results, Some("test calendar".into()));
1447    }
1448
1449    #[test]
1450    fn parse_prop_text() {
1451        let raw = br#"
1452<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="http://apple.com/ns/ical/">
1453  <ns0:response>
1454    <ns0:href>/user/calendars/pxE4Wt4twPqcWPbS/</ns0:href>
1455    <ns0:propstat>
1456      <ns0:status>HTTP/1.1 200 OK</ns0:status>
1457      <ns0:prop>
1458        <ns1:calendar-color>#ff00ff</ns1:calendar-color>
1459      </ns0:prop>
1460    </ns0:propstat>
1461  </ns0:response>
1462</ns0:multistatus>"#;
1463
1464        let results = extract_one_prop(raw, &CALENDAR_COLOUR).unwrap();
1465        assert_eq!(results, Some("#ff00ff".into()));
1466
1467        extract_one_prop(raw, &DISPLAY_NAME).unwrap_err();
1468    }
1469
1470    #[test]
1471    fn parse_prop() {
1472        // As returned by Fastmail.
1473        let body = concat!(
1474            "<?xml version=\"1.0\" encoding=\"utf-8\"?>",
1475            "<multistatus xmlns=\"DAV:\" xmlns:XB875=\"http://apple.com/ns/ical/\">",
1476            "<response>",
1477            "<href>/dav/calendars/user/vdirsyncer@fastmail.com/jEZCzRA0bV3DnRXD/</href>",
1478            "<propstat>",
1479            "<prop>",
1480            "<XB875:calendar-color><![CDATA[#ff00ff]]></XB875:calendar-color>",
1481            "</prop>",
1482            "<status>HTTP/1.1 200 OK</status>",
1483            "</propstat>",
1484            "</response>",
1485            "</multistatus>",
1486        );
1487        let parsed = extract_one_prop(body.as_bytes(), &names::CALENDAR_COLOUR).unwrap();
1488        assert_eq!(parsed, Some(String::from("#ff00ff")));
1489    }
1490
1491    #[test]
1492    fn test_multi_prop() {
1493        let body = concat!(
1494            "<C:calendar-user-address-set xmlns:D=\"DAV:\"",
1495            " xmlns:C=\"urn:ietf:params:xml:ns:caldav\">",
1496            " <D:href>mailto:bernard@example.com</D:href>",
1497            " <D:href>mailto:bernard.desruisseaux@example.com</D:href>",
1498            "</C:calendar-user-address-set>",
1499        );
1500        let parsed =
1501            extract_multi_prop(body.as_bytes(), &names::CALENDAR_USER_ADDRESS_SET).unwrap();
1502        let expected = vec![
1503            "mailto:bernard@example.com",
1504            "mailto:bernard.desruisseaux@example.com",
1505        ];
1506        assert_eq!(parsed, expected);
1507    }
1508
1509    #[test]
1510    fn find_hrefs_prop_as_uri() {
1511        // As returned by xandikos.
1512        let body = concat!(
1513            "<ns0:multistatus xmlns:ns0=\"DAV:\" xmlns:ns1=\"urn:ietf:params:xml:ns:carddav\">",
1514            "<ns0:response>",
1515            "<ns0:href>/user/</ns0:href>",
1516            "<ns0:propstat>",
1517            "<ns0:status>HTTP/1.1 200 OK</ns0:status>",
1518            "<ns0:prop>",
1519            "<ns1:addressbook-home-set>",
1520            "<ns0:href>/user/contacts/</ns0:href>",
1521            "</ns1:addressbook-home-set>",
1522            "</ns0:prop>",
1523            "</ns0:propstat>",
1524            "</ns0:response>",
1525            "</ns0:multistatus>",
1526        );
1527        let url = "http://localhost:8000/user/".parse().unwrap();
1528        let prop = names::ADDRESSBOOK_HOME_SET;
1529
1530        let parsed = extract_home_set_href(body.as_bytes(), &url, &prop).unwrap();
1531        let expected = "http://localhost:8000/user/contacts/"
1532            .parse::<Uri>()
1533            .unwrap();
1534        assert_eq!(parsed, vec![expected]);
1535    }
1536
1537    #[test]
1538    fn davmail_empty_principal() {
1539        // Response as returned by davmail.
1540        let body =  concat!(
1541            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
1542            "<D:multistatus xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" xmlns:E=\"urn:ietf:params:xml:ns:carddav\">",
1543            "<D:response>",
1544            "<D:href>/users/user@company.com/calendar/</D:href>",
1545            "<D:propstat>",
1546            "<D:prop>",
1547            "</D:prop>",
1548            "<D:status>HTTP/1.1 200 OK</D:status>",
1549            "</D:propstat>",
1550            "</D:response>",
1551            "</D:multistatus>",
1552        ).as_bytes();
1553
1554        let extracted = extract_principal_href(
1555            body,
1556            &Uri::from_static("http://localhost:1080/"),
1557            &names::CURRENT_USER_PRINCIPAL,
1558        )
1559        .unwrap();
1560
1561        assert!(extracted.is_none());
1562    }
1563}