1pub 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#[derive(thiserror::Error, Debug)]
59pub enum RequestError<E> {
60 #[error("executing http request: {0}")]
62 Http(#[from] hyper::Error),
63
64 #[error("client error executing request: {0}")]
66 Client(E),
67}
68
69#[derive(thiserror::Error, Debug)]
71pub enum WebDavError<E> {
72 #[error(transparent)]
74 Request(#[from] RequestError<E>),
75
76 #[error("missing field '{0}' in response XML")]
78 MissingData(&'static str),
79
80 #[error("invalid status code in response: {0}")]
82 InvalidStatusCode(#[from] InvalidStatusCode),
83
84 #[error("parsing XML response: {0}")]
86 Xml(#[from] roxmltree::Error),
87
88 #[error("http request returned {0}")]
90 BadStatusCode(http::StatusCode),
91
92 #[error("building URL with the given input: {0}")]
94 InvalidInput(#[from] http::Error),
95
96 #[error("response contains an invalid etag header: {0}")]
98 InvalidEtag(#[from] FromUtf8Error),
99
100 #[error("invalid response: {0}")]
102 InvalidResponse(Box<dyn std::error::Error + Send + Sync>),
103
104 #[error("precondition failed")]
106 PreconditionFailed(Precondition<'static>),
107
108 #[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#[derive(thiserror::Error, Debug)]
129pub enum ResolveContextPathError<E> {
130 #[error("creating uri and request with given parameters: {0}")]
132 BadInput(#[from] http::Error),
133
134 #[error("performing http request: {0}")]
136 Request(#[from] RequestError<E>),
137
138 #[error("missing Location header in response")]
140 MissingLocation,
141
142 #[error("building new Uri with Location from response: {0}")]
144 BadLocation(#[from] http::uri::InvalidUri),
145
146 #[error("too many redirections")]
148 TooManyRedirects,
149}
150
151#[derive(thiserror::Error, Debug)]
153pub enum FindCurrentUserPrincipalError<E> {
154 #[error("performing webdav request: {0}")]
156 RequestError(#[from] WebDavError<E>),
157
158 #[error("cannot use base_url to build request uri: {0}")]
162 InvalidInput(#[from] http::Error),
163}
164
165#[derive(Debug)]
287pub struct WebDavClient<C>
288where
289 C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + 'static,
290{
291 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 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 pub fn base_url(&self) -> &Uri {
314 &self.base_url
315 }
316
317 pub fn relative_uri(&self, path: &str) -> Result<Uri, http::Error> {
325 make_relative_url(self.base_url.clone(), path)
326 }
327
328 pub async fn find_current_user_principal(
343 &self,
344 ) -> Result<Option<Uri>, FindCurrentUserPrincipalError<C::Error>> {
345 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 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 }
378
379 pub async fn request_raw(
389 &self,
390 request: Request<String>,
391 ) -> Result<(Parts, Bytes), RequestError<C::Error>> {
392 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); 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 #[allow(clippy::missing_panics_doc)] 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 for i in 0..5 {
446 let request = Request::builder()
447 .method(Method::GET)
448 .uri(&uri)
449 .body(String::new())?;
450
451 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 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 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 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
560fn 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#[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
584pub mod mime_types {
586 pub const CALENDAR: &str = "text/calendar";
588 pub const ADDRESSBOOK: &str = "text/vcard";
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct ListedResource {
598 pub href: String,
602 pub status: Option<StatusCode>,
604 pub content_type: Option<String>,
606 pub etag: Option<String>,
610 pub resource_type: ResourceType,
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct FoundCollection {
622 pub href: String,
626 pub etag: Option<String>,
630 pub supports_sync: bool,
632 }
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 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 .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 #[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}