1use 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#[derive(thiserror::Error, Debug)]
34pub enum RequestError {
35 #[error("error executing http request: {0}")]
37 Http(#[from] hyper::Error),
38
39 #[error("client error executing request: {0}")]
41 Client(Box<dyn std::error::Error + Send + Sync>),
43}
44
45#[derive(thiserror::Error, Debug)]
47#[allow(clippy::module_name_repetitions)]
48pub enum WebDavError {
49 #[error("http client error: {0}")]
51 Request(#[from] RequestError),
52
53 #[error("missing field '{0}' in response XML")]
55 MissingData(&'static str),
56
57 #[error("invalid status code in response: {0}")]
59 InvalidStatusCode(#[from] InvalidStatusCode),
60
61 #[error("could not parse XML response: {0}")]
63 Xml(#[from] roxmltree::Error),
64
65 #[error("http request returned {0}")]
67 BadStatusCode(http::StatusCode),
68
69 #[error("failed to build URL with the given input: {0}")]
71 InvalidInput(#[from] http::Error),
72
73 #[error("the server returned an response with an invalid etag header: {0}")]
75 InvalidEtag(#[from] FromUtf8Error),
76
77 #[error("the server returned an invalid response: {0}")]
79 InvalidResponse(Box<dyn std::error::Error + Send + Sync>),
80
81 #[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#[derive(thiserror::Error, Debug)]
102pub enum ResolveContextPathError {
103 #[error("failed to create uri and request with given parameters: {0}")]
105 BadInput(#[from] http::Error),
106
107 #[error("error performing http request: {0}")]
109 Request(#[from] RequestError),
110
111 #[error("missing Location header in response")]
113 MissingLocation,
114
115 #[error("error building new Uri with Location from response: {0}")]
117 BadLocation(#[from] http::uri::InvalidUri),
118}
119
120#[derive(thiserror::Error, Debug)]
122pub enum FindCurrentUserPrincipalError {
123 #[error("error performing webdav request: {0}")]
125 RequestError(#[from] WebDavError),
126
127 #[error("cannot use base_url to build request uri: {0}")]
131 InvalidInput(#[from] http::Error),
132}
133
134#[derive(Debug)]
256pub struct WebDavClient<C>
257where
258 C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send + 'static,
259{
260 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 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 pub fn base_url(&self) -> &Uri {
283 &self.base_url
284 }
285
286 pub fn relative_uri(&self, path: &str) -> Result<Uri, http::Error> {
294 make_relative_url(self.base_url.clone(), path)
295 }
296
297 pub async fn find_current_user_principal(
312 &self,
313 ) -> Result<Option<Uri>, FindCurrentUserPrincipalError> {
314 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 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 }
345
346 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 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 pub async fn request(&self, request: Request<String>) -> Result<(Parts, Bytes), RequestError> {
394 let mut client = self.http_client.lock().await;
398 let response_future = client.call(request);
399 drop(client); 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 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 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 .or_else(|| {
487 root.descendants()
488 .find(|node| node.tag_name().name() == property.name())
489 })
490 .and_then(|p| p.text())
492 .map(str::to_owned);
493
494 results.push((*property, prop));
495 }
496 Ok(results)
497 }
498
499 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 #[allow(clippy::missing_panics_doc)] 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 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 let location = head
593 .headers
594 .get(hyper::header::LOCATION)
595 .ok_or(ResolveContextPathError::MissingLocation)?
596 .as_bytes();
597 let uri = Uri::try_from(location)?;
599
600 if uri.host().is_some() {
601 return Ok(Some(uri)); }
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 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 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 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 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 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 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 check_status(head.status)?;
757
758 Ok(())
759 }
760
761 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 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
828fn 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#[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
852pub mod mime_types {
854 pub const CALENDAR: &[u8] = b"text/calendar";
856 pub const ADDRESSBOOK: &[u8] = b"text/vcard";
858}
859
860#[derive(Debug, PartialEq)]
865pub struct ListedResource {
866 pub href: String,
870 pub status: Option<StatusCode>,
872 pub content_type: Option<String>,
874 pub etag: Option<String>,
878 pub resource_type: ResourceType,
882}
883
884#[derive(Debug)]
889pub struct FoundCollection {
890 pub href: String,
894 pub etag: Option<String>,
898 pub supports_sync: bool,
900 }
902
903pub(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 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 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 .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
1130fn 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")] fn list_resources_parse_404() {
1243 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 #[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 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 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 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}