Skip to main content

libdav/dav/
mod.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.
9
10pub mod check_support;
11pub mod delete;
12pub mod find_collections;
13pub mod find_property_hrefs;
14pub mod get_etag;
15pub mod get_properties;
16pub mod get_property;
17pub mod list_resources;
18pub mod propfind;
19pub mod put_resource;
20pub mod set_property;
21
22pub use check_support::{CheckSupport, CheckSupportParseError, CheckSupportResponse};
23pub use delete::{Delete, DeleteResponse};
24pub use find_collections::{FindCollections, FindCollectionsResponse};
25pub use find_property_hrefs::{FindPropertyHrefs, FindPropertyHrefsResponse};
26pub use get_etag::{GetEtag, GetEtagResponse};
27pub use get_properties::{GetProperties, GetPropertiesResponse};
28pub use get_property::{GetProperty, GetPropertyResponse};
29pub use list_resources::{ListResources, ListResourcesResponse};
30pub use propfind::{Propfind, PropfindResponse};
31pub use put_resource::{PutResource, PutResourceParseError, PutResourceResponse};
32pub use set_property::{SetProperty, SetPropertyResponse};
33
34use std::{string::FromUtf8Error, sync::Arc};
35
36use http::{
37    Method, Request, Response, StatusCode, Uri, response::Parts, status::InvalidStatusCode,
38    uri::PathAndQuery,
39};
40use http_body_util::BodyExt;
41use hyper::body::{Bytes, Incoming};
42use log::debug;
43use tokio::sync::Mutex;
44use tower_service::Service;
45
46use crate::{
47    FetchedResource, FetchedResourceContent, Precondition, PropertyName, ResourceType,
48    encoding::{NormalisationError, normalise_percent_encoded, strict_percent_encoded},
49    names,
50    requests::{DavRequest, ParseResponseError},
51    sd::DiscoverableService,
52    xmlutils::{
53        check_multistatus, get_newline_corrected_text, get_normalised_href, parse_statusline,
54    },
55};
56
57/// Error executing an HTTP request.
58#[derive(thiserror::Error, Debug)]
59pub enum RequestError<E> {
60    /// Error handling the HTTP stream.
61    #[error("executing http request: {0}")]
62    Http(#[from] hyper::Error),
63
64    /// Error from the underlying HTTP client.
65    #[error("client error executing request: {0}")]
66    Client(E),
67}
68
69/// Error for WebDAV operations.
70#[derive(thiserror::Error, Debug)]
71pub enum WebDavError<E> {
72    /// Error performing underlying HTTP request.
73    #[error(transparent)]
74    Request(#[from] RequestError<E>),
75
76    /// An expected field was missing in the HTTP response.
77    #[error("missing field '{0}' in response XML")]
78    MissingData(&'static str),
79
80    /// The server returned an invalid status code.
81    #[error("invalid status code in response: {0}")]
82    InvalidStatusCode(#[from] InvalidStatusCode),
83
84    /// Error parsing the XML response.
85    #[error("parsing XML response: {0}")]
86    Xml(#[from] roxmltree::Error),
87
88    /// The server returned an unexpected status code.
89    #[error("http request returned {0}")]
90    BadStatusCode(http::StatusCode),
91
92    /// An argument passed to build a URL was invalid.
93    #[error("building URL with the given input: {0}")]
94    InvalidInput(#[from] http::Error),
95
96    /// The Etag from the server is not a valid UTF-8 string.
97    #[error("response contains an invalid etag header: {0}")]
98    InvalidEtag(#[from] FromUtf8Error),
99
100    /// The server returned a response that did not contain valid data.
101    #[error("invalid response: {0}")]
102    InvalidResponse(Box<dyn std::error::Error + Send + Sync>),
103
104    /// Server rejected request due to failed precondition.
105    #[error("precondition failed")]
106    PreconditionFailed(Precondition<'static>),
107
108    /// The response is not valid UTF-8.
109    ///
110    /// At this time, other encodings are not supported.
111    #[error("decoding response as utf-8: {0}")]
112    NotUtf8(#[from] std::str::Utf8Error),
113}
114
115impl<E> From<StatusCode> for WebDavError<E> {
116    fn from(status: StatusCode) -> Self {
117        WebDavError::BadStatusCode(status)
118    }
119}
120
121impl<E> From<NormalisationError> for WebDavError<E> {
122    fn from(value: NormalisationError) -> Self {
123        WebDavError::InvalidResponse(value.into())
124    }
125}
126
127/// Error type for [`WebDavClient::find_context_path`].
128#[derive(thiserror::Error, Debug)]
129pub enum ResolveContextPathError<E> {
130    /// An argument passed to build a URL was invalid.
131    #[error("creating uri and request with given parameters: {0}")]
132    BadInput(#[from] http::Error),
133
134    /// Error performing underlying HTTP request.
135    #[error("performing http request: {0}")]
136    Request(#[from] RequestError<E>),
137
138    /// The response is missing a required Location header.
139    #[error("missing Location header in response")]
140    MissingLocation,
141
142    /// The Location from the server's response could not be used to build a new URL.
143    #[error("building new Uri with Location from response: {0}")]
144    BadLocation(#[from] http::uri::InvalidUri),
145
146    /// Too many redirections were encountered.
147    #[error("too many redirections")]
148    TooManyRedirects,
149}
150
151/// Error type for [`WebDavClient::find_current_user_principal`]
152#[derive(thiserror::Error, Debug)]
153pub enum FindCurrentUserPrincipalError<E> {
154    /// Error performing underlying HTTP request.
155    #[error("performing webdav request: {0}")]
156    RequestError(#[from] WebDavError<E>),
157
158    /// The `base_url` is not valid or could not be used to build a request URL.
159    ///
160    /// Should not happen unless there is a bug in `hyper`.
161    #[error("cannot use base_url to build request uri: {0}")]
162    InvalidInput(#[from] http::Error),
163}
164
165/// Generic WebDAV client.
166///
167/// A WebDAV client that uses a parametrised http client `C` to perform the underlying HTTP
168/// requests.
169///
170/// An existing http client that can be used is `hyper_util::client::legacy::Client`, although any
171/// client which implements the trait bounds is acceptable. Essentially an http clients needs to
172/// implement [`tower_service::Service`], taking a [`Request<Service>`] as input and returning a
173/// [`Response<Incoming>`].
174///
175/// The provided http client can simply be one that wraps around an existing one.
176/// These wrappers are called middleware in the Tower/Hyper ecosystem.
177///
178/// The most common and obvious example is one that adds an `Authorization` header to all outgoing
179/// requests:
180///
181/// ```rust
182/// # use libdav::dav::WebDavClient;
183/// use http::Uri;
184/// use hyper_rustls::HttpsConnectorBuilder;
185/// use hyper_util::{client::legacy::Client, rt::TokioExecutor};
186/// use tower_http::auth::AddAuthorization;
187///
188/// # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async {
189/// let uri = Uri::try_from("https://example.com").unwrap();
190///
191/// let https_connector = HttpsConnectorBuilder::new()
192///     .with_native_roots()
193///     .unwrap()
194///     .https_or_http()
195///     .enable_http1()
196///     .build();
197/// let http_client = Client::builder(TokioExecutor::new()).build(https_connector);
198/// let auth_client = AddAuthorization::basic(http_client, "user", "secret");
199/// let webdav = WebDavClient::new(uri, auth_client);
200/// # })
201/// ```
202///
203/// The concrete type of the client in the above example is somewhat complex. For this reason,
204/// application code will usually want to use an alias for the concrete type being used, and use
205/// this alias through all types and functions that handle the WebDAV client:
206///
207/// ```rust
208/// # use hyper_rustls::HttpsConnector;
209/// # use hyper_util::client::legacy::{connect::HttpConnector, Client};
210/// # use libdav::dav::WebDavClient;
211/// # use tower_http::auth::AddAuthorization;
212/// type MyClient = WebDavClient<AddAuthorization<Client<HttpsConnector<HttpConnector>, String>>>;
213/// ```
214///
215/// # Setting a custom User-Agent header
216///
217/// The following example uses a custom middleware  which sets a specific User-Agent on each
218/// outgoing request:
219///
220/// ```rust
221/// use std::task::{Context, Poll};
222///
223/// use hyper::{
224///     header::{HeaderValue, USER_AGENT},
225///     Request, Response,
226/// };
227/// use tower_service::Service;
228///
229/// #[derive(Debug, Clone)]
230/// pub struct UserAgent<S> {
231///     inner: S,
232///     user_agent: HeaderValue,
233/// }
234///
235/// impl<S> UserAgent<S> {
236///     /// Add a custom User-Agent to outgoing requests.
237///     pub fn new(inner: S, user_agent: HeaderValue) -> UserAgent<S> {
238///         UserAgent { inner, user_agent }
239///     }
240/// }
241///
242/// impl<S, Tx, Rx> Service<Request<Tx>> for UserAgent<S>
243/// where
244///     S: Service<Request<Tx>, Response = Response<Rx>>,
245/// {
246///     type Response = S::Response;
247///     type Error = S::Error;
248///     type Future = S::Future;
249///
250///     fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
251///         self.inner.poll_ready(cx)
252///     }
253///
254///     fn call(&mut self, mut req: Request<Tx>) -> Self::Future {
255///         req.headers_mut()
256///             .insert(USER_AGENT, self.user_agent.clone());
257///         self.inner.call(req)
258///     }
259/// }
260///
261/// // Elsewhere in your codebase...
262/// # use libdav::dav::WebDavClient;
263/// use http::Uri;
264/// use hyper_rustls::HttpsConnectorBuilder;
265/// use hyper_util::{client::legacy::Client, rt::TokioExecutor};
266/// use tower_http::auth::AddAuthorization;
267///
268/// # tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async {
269/// let uri = Uri::try_from("https://example.com").unwrap();
270///
271/// let https_connector = HttpsConnectorBuilder::new()
272///     .with_native_roots()
273///     .unwrap()
274///     .https_or_http()
275///     .enable_http1()
276///     .build();
277/// let http_client = Client::builder(TokioExecutor::new()).build(https_connector);
278/// let auth_client = UserAgent::new(http_client, "myapp/0.2.7".try_into().unwrap());
279/// let webdav = WebDavClient::new(uri, auth_client);
280/// # })
281/// ```
282///
283/// For other generic middleware of this style, consult the [tower-http] crate.
284///
285/// [tower-http]: https://docs.rs/tower-http/
286#[derive(Debug)]
287pub struct WebDavClient<C>
288where
289    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + 'static,
290{
291    /// Base URL to be used for all requests.
292    ///
293    /// Composed of the domain+port used for the server, plus the context path where WebDAV
294    /// requests are served.
295    pub base_url: Uri,
296    http_client: Arc<Mutex<C>>,
297}
298
299impl<C> WebDavClient<C>
300where
301    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send,
302    <C as Service<http::Request<String>>>::Error: std::error::Error + Send + Sync,
303{
304    /// Builds a new WebDAV client.
305    pub fn new(base_url: Uri, http_client: C) -> WebDavClient<C> {
306        WebDavClient {
307            base_url,
308            http_client: Arc::new(Mutex::from(http_client)),
309        }
310    }
311
312    /// Returns a URL pointing to the server's context path.
313    pub fn base_url(&self) -> &Uri {
314        &self.base_url
315    }
316
317    /// Returns a new URI relative to the server's root.
318    ///
319    /// `path` MUST NOT be percent-encoded, except for any reserved characters.
320    ///
321    /// # Errors
322    ///
323    /// If this client's `base_url` is invalid or the provided `path` is not an acceptable path.
324    pub fn relative_uri(&self, path: &str) -> Result<Uri, http::Error> {
325        make_relative_url(self.base_url.clone(), path)
326    }
327
328    /// Resolves the current user's principal resource.
329    ///
330    /// First queries the `base_url`, then the root path on the same host.
331    ///
332    /// Returns `None` if the response's status code is 404 or if no principal was found.
333    ///
334    /// # Errors
335    ///
336    /// See [`FindCurrentUserPrincipalError`]
337    ///
338    /// # See also
339    ///
340    /// The `DAV:current-user-principal` property is defined in
341    /// <https://www.rfc-editor.org/rfc/rfc5397#section-3>
342    pub async fn find_current_user_principal(
343        &self,
344    ) -> Result<Option<Uri>, FindCurrentUserPrincipalError<C::Error>> {
345        // Try querying the provided base url...
346        let result = self
347            .request(FindPropertyHrefs::new(
348                &self.base_url,
349                &names::CURRENT_USER_PRINCIPAL,
350            ))
351            .await;
352
353        match result {
354            Ok(response) => {
355                if let Some(uri) = response.hrefs.into_iter().next() {
356                    return Ok(Some(uri));
357                }
358            }
359            Err(WebDavError::BadStatusCode(StatusCode::NOT_FOUND)) => {}
360            Err(err) => return Err(FindCurrentUserPrincipalError::RequestError(err)),
361        }
362        debug!("User principal not found at base_url, trying root...");
363
364        // ... Otherwise, try querying the root path.
365        let root = self.relative_uri("/")?;
366        let response = self
367            .request(FindPropertyHrefs::new(
368                &root,
369                &names::CURRENT_USER_PRINCIPAL,
370            ))
371            .await?;
372
373        Ok(response.hrefs.into_iter().next())
374
375        // NOTE: If no principal is resolved, it needs to be provided interactively
376        //       by the user. We use `base_url` as a fallback.
377    }
378
379    /// Send a raw request to the server.
380    ///
381    /// Sends a request, applying any necessary authentication and logging the response.
382    ///
383    /// Lower-level API, prefer using [`WebDavClient::request`] with the Requests API instead.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if the underlying http request fails or if streaming the response fails.
388    pub async fn request_raw(
389        &self,
390        request: Request<String>,
391    ) -> Result<(Parts, Bytes), RequestError<C::Error>> {
392        // QUIRK: When trying to fetch a resource on a URL that is a collection, iCloud
393        // will terminate the connection (which returns "unexpected end of file").
394
395        log::trace!(
396            "Sending {:?} request to {:?}, body={:?}, headers={:?}",
397            request.method(),
398            request.uri(),
399            request.body(),
400            request.headers()
401        );
402
403        let mut client = self.http_client.lock().await;
404        let response_future = client.call(request);
405        drop(client); // Unlock http_client.
406
407        let response = response_future.await.map_err(RequestError::Client)?;
408        let (head, body) = response.into_parts();
409        let body = body.collect().await?.to_bytes();
410
411        log::trace!("Response ({}): {:?}", head.status, body);
412        Ok((head, body))
413    }
414
415    /// Resolve the default context path using a well-known path.
416    ///
417    /// Only applies for servers supporting WebDAV extensions like CalDAV or CardDAV. Returns
418    /// `Ok(None)` if the well-known path does not redirect to another location.
419    ///
420    /// # Errors
421    ///
422    /// - If the provided scheme, host and port cannot be used to construct a valid URL.
423    /// - If there are any network errors.
424    /// - If the response is not an HTTP redirection.
425    /// - If the `Location` header in the response is missing or invalid.
426    ///
427    /// # See also
428    ///
429    /// - <https://www.rfc-editor.org/rfc/rfc6764#section-5>
430    /// - [`ResolveContextPathError`]
431    #[allow(clippy::missing_panics_doc)] // panic condition is unreachable.
432    pub async fn find_context_path(
433        &self,
434        service: DiscoverableService,
435        host: &str,
436        port: u16,
437    ) -> Result<Option<Uri>, ResolveContextPathError<C::Error>> {
438        let mut uri = Uri::builder()
439            .scheme(service.scheme())
440            .authority(format!("{host}:{port}"))
441            .path_and_query(service.well_known_path())
442            .build()?;
443
444        // Max 5 redirections.
445        for i in 0..5 {
446            let request = Request::builder()
447                .method(Method::GET)
448                .uri(&uri)
449                .body(String::new())?;
450
451            // From https://www.rfc-editor.org/rfc/rfc6764#section-5:
452            // > [...] the server MAY require authentication when a client tries to
453            // > access the ".well-known" URI
454            let (head, _body) = self.request_raw(request).await?;
455            log::debug!("Response finding context path: {}", head.status);
456
457            if !head.status.is_redirection() {
458                // If first request is not a redirect, no context path found.
459                // Otherwise, we've reached the final destination.
460                return Ok(if i == 0 { None } else { Some(uri) });
461            }
462
463            let location = head
464                .headers
465                .get(hyper::header::LOCATION)
466                .ok_or(ResolveContextPathError::MissingLocation)?
467                .as_bytes();
468            // TODO: Review percent-encoding; a header can contain spaces.
469            uri = Uri::try_from(location)?;
470
471            if uri.host().is_none() {
472                let mut parts = uri.into_parts();
473                if parts.scheme.is_none() {
474                    parts.scheme = Some(service.scheme());
475                }
476                if parts.authority.is_none() {
477                    parts.authority = Some(format!("{host}:{port}").try_into()?);
478                }
479                uri = Uri::from_parts(parts).expect("uri parts are already validated");
480            }
481        }
482
483        Err(ResolveContextPathError::TooManyRedirects)
484    }
485
486    /// Execute a typed DAV request.
487    ///
488    /// Provides a type-safe way to execute WebDAV operations using typed requests and response
489    /// types. Each request type implements the [`crate::requests::DavRequest`] trait, and can
490    /// therefore:
491    ///
492    /// - Serialise itself into an HTTP request.
493    /// - Parse the HTTP response into a typed response.
494    ///
495    /// # Example
496    ///
497    /// ```
498    /// # use libdav::dav::WebDavClient;
499    /// # use libdav::dav::GetEtag;
500    /// # use tower_service::Service;
501    /// # async fn example<C>(webdav: &WebDavClient<C>) -> Result<(), Box<dyn std::error::Error>>
502    /// # where
503    /// #     C: Service<http::Request<String>, Response = http::Response<hyper::body::Incoming>> + Send + Sync,
504    /// #     C::Error: std::error::Error + Send + Sync,
505    /// # {
506    /// use libdav::dav::GetEtag;
507    ///
508    /// let response = webdav.request(
509    ///     GetEtag::new("/calendar/event.ics")
510    /// ).await?;
511    ///
512    /// println!("Etag: {}", response.etag);
513    /// # Ok(())
514    /// # }
515    /// ```
516    ///
517    /// # Errors
518    ///
519    /// Returns an error if:
520    /// - The request cannot be prepared (e.g., invalid parameters).
521    /// - The HTTP request fails.
522    /// - The response cannot be parsed.
523    pub async fn request<R>(&self, request: R) -> Result<R::Response, R::Error<C::Error>>
524    where
525        R: DavRequest,
526        R::Error<C::Error>: From<http::Error>,
527        R::Error<C::Error>: From<RequestError<C::Error>>,
528        R::Error<C::Error>: From<R::ParseError>,
529    {
530        let prepared = request.prepare_request()?;
531
532        let mut http_request = Request::builder()
533            .method(prepared.method)
534            .uri(self.relative_uri(&prepared.path)?);
535        for (name, value) in prepared.headers {
536            http_request = http_request.header(name, value);
537        }
538        let http_request = http_request.body(prepared.body)?;
539
540        let (head, body) = self.request_raw(http_request).await?;
541
542        let response = request.parse_response(&head, &body)?;
543
544        Ok(response)
545    }
546}
547
548impl<C> Clone for WebDavClient<C>
549where
550    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + Clone,
551{
552    fn clone(&self) -> WebDavClient<C> {
553        WebDavClient {
554            base_url: self.base_url.clone(),
555            http_client: self.http_client.clone(),
556        }
557    }
558}
559
560/// Make a new url using the schema and authority from `base` with the supplied `path`.
561///
562/// `path` MUST NOT be percent-encoded, except for any reserved characters.
563///
564/// # Errors
565///
566/// If this client's `base_url` is invalid or the provided `path` is not an acceptable path.
567fn make_relative_url(base: Uri, path: &str) -> Result<Uri, http::Error> {
568    let path = strict_percent_encoded(path);
569    let mut parts = base.into_parts();
570    parts.path_and_query = Some(PathAndQuery::try_from(path.as_ref())?);
571    Uri::from_parts(parts).map_err(http::Error::from)
572}
573
574/// Checks if the status code is success. If it is not, return it as an error.
575#[inline]
576pub(crate) fn check_status(status: StatusCode) -> Result<(), StatusCode> {
577    if status.is_success() {
578        Ok(())
579    } else {
580        Err(status)
581    }
582}
583
584/// Mime-types commonly used with this library.
585pub mod mime_types {
586    /// `text/calendar` mime-type.
587    pub const CALENDAR: &str = "text/calendar";
588    /// `text/vcard` mime-type.
589    pub const ADDRESSBOOK: &str = "text/vcard";
590}
591
592/// Metadata for a resource.
593///
594/// Returned when listing resources. It contains metadata on
595/// resources but no the resource data itself.
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct ListedResource {
598    /// The path component to a collection.
599    ///
600    /// Should be treated as an opaque string. Only reserved characters are percent-encoded.
601    pub href: String,
602    /// Status code for this resource, as returned by the server.
603    pub status: Option<StatusCode>,
604    /// The value of the `Content-Type` header, if any.
605    pub content_type: Option<String>,
606    /// The entity tag reflecting the version of the fetched resource.
607    ///
608    /// See: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag>
609    pub etag: Option<String>,
610    /// DAV-specific resource type.
611    ///
612    /// This field is subject to change.
613    pub resource_type: ResourceType,
614}
615
616/// Metadata for a collection.
617///
618/// Returned when listing collections. Contains metadata on collection itself,
619/// but not the entries themselves.
620#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct FoundCollection {
622    /// The path component to a collection.
623    ///
624    /// Should be treated as an opaque string. Only reserved characters are percent-encoded.
625    pub href: String,
626    /// The entity tag reflecting the version of the fetched resource.
627    ///
628    /// See: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag>
629    pub etag: Option<String>,
630    /// From: <https://www.rfc-editor.org/rfc/rfc6578>
631    pub supports_sync: bool,
632    // TODO: query displayname by default too.
633}
634
635pub(crate) fn extract_listed_resources(
636    body: &[u8],
637    collection_href: &str,
638) -> Result<Vec<ListedResource>, ParseResponseError> {
639    let body = std::str::from_utf8(body)?;
640    let doc = roxmltree::Document::parse(body)?;
641    let root = doc.root_element();
642    let responses = root
643        .descendants()
644        .filter(|node| node.tag_name() == names::RESPONSE);
645
646    let mut items = Vec::new();
647    for response in responses {
648        let href = get_normalised_href(&response)?.to_string();
649
650        // Don't list the collection itself.
651        if href == collection_href {
652            continue;
653        }
654
655        let status = response
656            .descendants()
657            .find(|node| node.tag_name() == names::STATUS)
658            .and_then(|node| node.text().map(str::to_string))
659            .as_deref()
660            .map(parse_statusline)
661            .transpose()?;
662        let etag = response
663            .descendants()
664            .find(|node| node.tag_name() == names::GETETAG)
665            .and_then(|node| node.text().map(str::to_string));
666        let content_type = response
667            .descendants()
668            .find(|node| node.tag_name() == names::GETCONTENTTYPE)
669            .and_then(|node| node.text().map(str::to_string));
670        let resource_type = if let Some(r) = response
671            .descendants()
672            .find(|node| node.tag_name() == names::RESOURCETYPE)
673        {
674            ResourceType {
675                is_calendar: r.descendants().any(|n| n.tag_name() == names::CALENDAR),
676                is_collection: r.descendants().any(|n| n.tag_name() == names::COLLECTION),
677                is_address_book: r.descendants().any(|n| n.tag_name() == names::ADDRESSBOOK),
678            }
679        } else {
680            ResourceType::default()
681        };
682
683        items.push(ListedResource {
684            href,
685            status,
686            content_type,
687            etag,
688            resource_type,
689        });
690    }
691
692    Ok(items)
693}
694
695pub(crate) fn extract_fetched_resources(
696    body: &[u8],
697    property: &PropertyName<'_, '_>,
698) -> Result<Vec<FetchedResource>, ParseResponseError> {
699    let body = std::str::from_utf8(body)?;
700    let doc = roxmltree::Document::parse(body)?;
701    let responses = doc
702        .root_element()
703        .descendants()
704        .filter(|node| node.tag_name() == names::RESPONSE);
705
706    let mut items = Vec::new();
707    for response in responses {
708        let status = match check_multistatus(response) {
709            Ok(()) => None,
710            Err(ParseResponseError::BadStatusCode(status)) => Some(status),
711            Err(e) => return Err(e),
712        };
713
714        let has_propstat = response // There MUST be zero or one propstat.
715            .descendants()
716            .any(|node| node.tag_name() == names::PROPSTAT);
717
718        if has_propstat {
719            let href = get_normalised_href(&response)?.to_string();
720
721            if let Some(status) = status {
722                items.push(FetchedResource {
723                    href,
724                    content: Err(status),
725                });
726                continue;
727            }
728
729            let etag = response
730                .descendants()
731                .find(|node| node.tag_name() == names::GETETAG)
732                .ok_or(ParseResponseError::InvalidResponse(
733                    "missing etag in response".into(),
734                ))?
735                .text()
736                .ok_or(ParseResponseError::InvalidResponse(
737                    "missing text in etag".into(),
738                ))?
739                .to_string();
740            let data = get_newline_corrected_text(&response, property)?;
741
742            items.push(FetchedResource {
743                href,
744                content: Ok(FetchedResourceContent { data, etag }),
745            });
746        } else {
747            let hrefs = response
748                .descendants()
749                .filter(|node| node.tag_name() == names::HREF);
750
751            for href in hrefs {
752                let href = href.text().ok_or(ParseResponseError::InvalidResponse(
753                    "missing text in href".into(),
754                ))?;
755                let href = normalise_percent_encoded(href)?.to_string();
756                let status = status.ok_or(ParseResponseError::InvalidResponse(
757                    "missing props but no error status code".into(),
758                ))?;
759                items.push(FetchedResource {
760                    href,
761                    content: Err(status),
762                });
763            }
764        }
765    }
766
767    Ok(items)
768}
769
770#[cfg(test)]
771mod more_tests {
772
773    use http::StatusCode;
774
775    use crate::{
776        FetchedResource, FetchedResourceContent, ResourceType,
777        dav::{ListedResource, extract_fetched_resources, extract_listed_resources},
778        names::{self, CALENDAR_DATA},
779    };
780
781    #[test]
782    fn multi_get_parse() {
783        let raw = br#"
784<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
785  <response>
786    <href>/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/</href>
787    <propstat>
788      <prop>
789        <resourcetype>
790          <collection/>
791          <C:calendar/>
792        </resourcetype>
793        <getcontenttype>text/calendar; charset=utf-8</getcontenttype>
794        <getetag>"1591712486-1-1"</getetag>
795      </prop>
796      <status>HTTP/1.1 200 OK</status>
797    </propstat>
798  </response>
799  <response>
800    <href>/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/395b00a0-eebc-40fd-a98e-176a06367c82.ics</href>
801    <propstat>
802      <prop>
803        <resourcetype/>
804        <getcontenttype>text/calendar; charset=utf-8; component=VEVENT</getcontenttype>
805        <getetag>"e7577ff2b0924fe8e9a91d3fb2eb9072598bf9fb"</getetag>
806      </prop>
807      <status>HTTP/1.1 200 OK</status>
808    </propstat>
809  </response>
810</multistatus>"#;
811
812        let results = extract_listed_resources(
813            raw,
814            "/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/",
815        )
816        .unwrap();
817
818        assert_eq!(results, vec![ListedResource {
819            content_type: Some("text/calendar; charset=utf-8; component=VEVENT".into()),
820            etag: Some("\"e7577ff2b0924fe8e9a91d3fb2eb9072598bf9fb\"".into()),
821            resource_type: ResourceType {
822                is_collection: false,
823                is_calendar: false,
824                is_address_book: false
825            },
826            href: "/dav/calendars/user/vdirsyncer@fastmail.com/cc396171-0227-4e1c-b5ee-d42b5e17d533/395b00a0-eebc-40fd-a98e-176a06367c82.ics".into(),
827            status: Some(StatusCode::OK),
828        }]);
829    }
830
831    #[test]
832    fn multi_get_parse_with_err() {
833        let raw = br#"
834<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:caldav">
835  <ns0:response>
836    <ns0:href>/user/calendars/Q208cKvMGjAdJFUw/qJJ9Li5DPJYr.ics</ns0:href>
837    <ns0:propstat>
838      <ns0:status>HTTP/1.1 200 OK</ns0:status>
839      <ns0:prop>
840        <ns0:getetag>"adb2da8d3cb1280a932ed8f8a2e8b4ecf66d6a02"</ns0:getetag>
841        <ns1:calendar-data>CALENDAR-DATA-HERE</ns1:calendar-data>
842      </ns0:prop>
843    </ns0:propstat>
844  </ns0:response>
845  <ns0:response>
846    <ns0:href>/user/calendars/Q208cKvMGjAdJFUw/rKbu4uUn.ics</ns0:href>
847    <ns0:status>HTTP/1.1 404 Not Found</ns0:status>
848  </ns0:response>
849</ns0:multistatus>
850"#;
851
852        let results = extract_fetched_resources(raw, &CALENDAR_DATA).unwrap();
853
854        assert_eq!(
855            results,
856            vec![
857                FetchedResource {
858                    href: "/user/calendars/Q208cKvMGjAdJFUw/qJJ9Li5DPJYr.ics".into(),
859                    content: Ok(FetchedResourceContent {
860                        data: "CALENDAR-DATA-HERE".into(),
861                        etag: "\"adb2da8d3cb1280a932ed8f8a2e8b4ecf66d6a02\"".into(),
862                    })
863                },
864                FetchedResource {
865                    href: "/user/calendars/Q208cKvMGjAdJFUw/rKbu4uUn.ics".into(),
866                    content: Err(StatusCode::NOT_FOUND)
867                }
868            ]
869        );
870    }
871
872    #[test]
873    fn multi_get_parse_mixed() {
874        let raw = br#"
875<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
876    <d:response>
877        <d:href>/remote.php/dav/calendars/vdirsyncer/1678996875/</d:href>
878        <d:propstat>
879            <d:prop>
880                <d:resourcetype>
881                    <d:collection/>
882                    <cal:calendar/>
883                </d:resourcetype>
884            </d:prop>
885            <d:status>HTTP/1.1 200 OK</d:status>
886        </d:propstat>
887        <d:propstat>
888            <d:prop>
889                <d:getetag/>
890            </d:prop>
891            <d:status>HTTP/1.1 404 Not Found</d:status>
892        </d:propstat>
893    </d:response>
894</d:multistatus>"#;
895
896        let results = extract_fetched_resources(raw, &CALENDAR_DATA).unwrap();
897
898        assert_eq!(
899            results,
900            vec![FetchedResource {
901                href: "/remote.php/dav/calendars/vdirsyncer/1678996875/".into(),
902                content: Err(StatusCode::NOT_FOUND)
903            }]
904        );
905    }
906
907    #[test]
908    fn multi_get_parse_encoding() {
909        let b = r#"<?xml version="1.0" encoding="utf-8"?>
910<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
911  <response>
912    <href>/dav/calendars/user/hugo@whynothugo.nl/2100F960-2655-4E75-870F-CAA793466105/0F276A13-FBF3-49A1-8369-65EEA9C6F891.ics</href>
913    <propstat>
914      <prop>
915        <getetag>"4219b87012f42ce7c4db55599aa3b579c70d8795"</getetag>
916        <C:calendar-data><![CDATA[BEGIN:VCALENDAR
917CALSCALE:GREGORIAN
918PRODID:-//Apple Inc.//iOS 17.0//EN
919VERSION:2.0
920BEGIN:VTODO
921COMPLETED:20230425T155913Z
922CREATED:20210622T182718Z
923DTSTAMP:20230915T132714Z
924LAST-MODIFIED:20230425T155913Z
925PERCENT-COMPLETE:100
926SEQUENCE:1
927STATUS:COMPLETED
928SUMMARY:Comidas: ñoquis, 西红柿
929UID:0F276A13-FBF3-49A1-8369-65EEA9C6F891
930X-APPLE-SORT-ORDER:28
931END:VTODO
932END:VCALENDAR
933]]></C:calendar-data>
934      </prop>
935      <status>HTTP/1.1 200 OK</status>
936    </propstat>
937  </response>
938</multistatus>"#;
939
940        let resources = extract_fetched_resources(b.as_bytes(), &names::CALENDAR_DATA).unwrap();
941        let content = resources.into_iter().next().unwrap().content.unwrap();
942        assert!(content.data.contains("ñoquis"));
943        assert!(content.data.contains("西红柿"));
944    }
945
946    /// See: <https://github.com/RazrFalcon/roxmltree/issues/108>
947    #[test]
948    fn multi_get_parse_encoding_another() {
949        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";
950        let resources = extract_fetched_resources(b.as_bytes(), &names::CALENDAR_DATA).unwrap();
951        let content = resources.into_iter().next().unwrap().content.unwrap();
952        assert!(content.data.contains("baño"));
953    }
954}