1pub mod copy;
23pub mod coroutine;
24pub mod delete;
25pub mod follow_redirects;
26pub mod get;
27pub mod mkcol;
28pub mod r#move;
29pub mod options;
30pub mod propfind;
31pub mod proppatch;
32pub mod put;
33pub mod report;
34pub mod request;
35pub mod send;
36
37use alloc::{
38 format,
39 string::{String, ToString},
40 vec::{self, Vec},
41};
42
43use io_http::{
44 rfc6750::bearer::HttpAuthBearer, rfc7617::basic::HttpAuthBasic, rfc9110::response::HttpResponse,
45};
46use log::trace;
47use quick_xml::{Reader, events::Event};
48use url::Url;
49
50#[derive(Clone, Debug, Default)]
58pub enum WebdavAuth {
59 #[default]
61 None,
62
63 Basic(HttpAuthBasic),
65
66 Bearer(HttpAuthBearer),
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub struct Namespace {
80 pub uri: &'static str,
82 pub prefix: &'static str,
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub struct Property {
96 pub ns: Namespace,
98 pub local: &'static str,
100}
101
102#[derive(Clone, Debug, Default)]
105pub struct Multistatus {
106 pub responses: Vec<ResponseEntry>,
108
109 pub sync_token: Option<String>,
112}
113
114impl IntoIterator for Multistatus {
115 type Item = ResponseEntry;
116 type IntoIter = vec::IntoIter<ResponseEntry>;
117
118 fn into_iter(self) -> Self::IntoIter {
119 self.responses.into_iter()
120 }
121}
122
123#[derive(Clone, Debug, Default)]
126pub struct ResponseEntry {
127 pub href: String,
129 pub status: Option<u16>,
134 pub props: Vec<PropItem>,
136}
137
138impl ResponseEntry {
139 pub fn prop(&self, prop: Property) -> Option<&PropItem> {
141 self.props.iter().find(|item| item.local == prop.local)
142 }
143
144 pub fn text(&self, prop: Property) -> Option<&str> {
146 self.prop(prop)
147 .map(|item| item.text.trim())
148 .filter(|text| !text.is_empty())
149 }
150
151 pub fn has_resource_type(&self, resourcetype: Property, ty: Property) -> bool {
154 self.prop(resourcetype)
155 .is_some_and(|item| item.children.iter().any(|child| child == ty.local))
156 }
157
158 pub fn id(&self) -> &str {
161 self.href
162 .trim_end_matches('/')
163 .rsplit('/')
164 .next()
165 .unwrap_or("")
166 }
167}
168
169#[derive(Clone, Debug, Default)]
171pub struct PropItem {
172 pub local: String,
174 pub text: String,
177 pub children: Vec<String>,
180}
181
182pub const DAV: Namespace = Namespace {
190 uri: "DAV:",
191 prefix: "D",
192};
193pub const CALENDARSERVER: Namespace = Namespace {
196 uri: "http://calendarserver.org/ns/",
197 prefix: "CS",
198};
199
200pub const XML_DECL: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>";
202
203pub const DISPLAYNAME: Property = Property {
205 ns: DAV,
206 local: "displayname",
207};
208pub const RESOURCETYPE: Property = Property {
210 ns: DAV,
211 local: "resourcetype",
212};
213pub const GETETAG: Property = Property {
215 ns: DAV,
216 local: "getetag",
217};
218pub const SYNC_TOKEN: Property = Property {
220 ns: DAV,
221 local: "sync-token",
222};
223pub const GETCTAG: Property = Property {
226 ns: CALENDARSERVER,
227 local: "getctag",
228};
229
230const PROPERTYUPDATE: Property = Property {
232 ns: DAV,
233 local: "propertyupdate",
234};
235
236pub fn xmlns_decls(namespaces: &[Namespace]) -> String {
240 let mut seen: Vec<&str> = Vec::new();
241 let mut out = String::new();
242
243 for ns in namespaces {
244 if seen.contains(&ns.uri) {
245 continue;
246 }
247 seen.push(ns.uri);
248
249 if ns.prefix.is_empty() {
250 out.push_str(&format!(" xmlns=\"{}\"", ns.uri));
251 } else {
252 out.push_str(&format!(" xmlns:{}=\"{}\"", ns.prefix, ns.uri));
253 }
254 }
255
256 out
257}
258
259pub fn escape_text(text: &str) -> String {
261 text.replace('&', "&")
262 .replace('<', "<")
263 .replace('>', ">")
264}
265
266pub fn prop_block(props: &[Property]) -> String {
268 let mut out = String::from("<D:prop>");
269 for prop in props {
270 out.push_str(&empty_element(*prop));
271 }
272 out.push_str("</D:prop>");
273 out
274}
275
276pub fn propfind_body(props: &[Property]) -> Vec<u8> {
278 let decls = xmlns_decls(&namespaces(&[], props));
279 let mut body = format!("{XML_DECL}<D:propfind{decls}>");
280 body.push_str(&prop_block(props));
281 body.push_str("</D:propfind>");
282 body.into_bytes()
283}
284
285pub fn proppatch_body(set: &[(Property, &str)]) -> Vec<u8> {
288 prop_set_body(PROPERTYUPDATE, set)
289}
290
291pub fn prop_set_body(root: Property, set: &[(Property, &str)]) -> Vec<u8> {
296 let props: Vec<Property> = set.iter().map(|(prop, _)| *prop).collect();
297 let mut nss = namespaces(&[], &props);
298 nss.push(root.ns);
299 let decls = xmlns_decls(&nss);
300 let open = qualified(root.ns, root.local);
301
302 let mut body = format!("{XML_DECL}<{open}{decls}><D:set><D:prop>");
303 for (prop, value) in set {
304 body.push_str(&value_element(*prop, value));
305 }
306 body.push_str(&format!("</D:prop></D:set></{open}>"));
307 body.into_bytes()
308}
309
310pub fn mkcol_body(resource_types: &[Property], set: &[(Property, &str)]) -> Vec<u8> {
314 let mut props: Vec<Property> = resource_types.to_vec();
315 props.extend(set.iter().map(|(prop, _)| *prop));
316 let decls = xmlns_decls(&namespaces(&[], &props));
317
318 let mut body =
319 format!("{XML_DECL}<D:mkcol{decls}><D:set><D:prop><D:resourcetype><D:collection/>");
320 for resource_type in resource_types {
321 body.push_str(&empty_element(*resource_type));
322 }
323 body.push_str("</D:resourcetype>");
324 for (prop, value) in set {
325 body.push_str(&value_element(*prop, value));
326 }
327 body.push_str("</D:prop></D:set></D:mkcol>");
328 body.into_bytes()
329}
330
331pub fn report_query_body(
336 root: Property,
337 extra_ns: &[Namespace],
338 props: &[Property],
339 filter: &str,
340) -> Vec<u8> {
341 let mut nss = namespaces(extra_ns, props);
342 nss.push(root.ns);
343 let decls = xmlns_decls(&nss);
344
345 let open = qualified(root.ns, root.local);
346
347 let mut body = format!("{XML_DECL}<{open}{decls}>");
348 body.push_str(&prop_block(props));
349 body.push_str(filter);
350 body.push_str(&format!("</{open}>"));
351 body.into_bytes()
352}
353
354pub fn parse_multistatus(xml: &str) -> Multistatus {
364 let mut reader = Reader::from_str(xml);
365
366 let mut responses: Vec<ResponseEntry> = Vec::new();
367 let mut sync_token: Option<String> = None;
368 let mut stack: Vec<(String, String, Vec<String>)> = Vec::new();
370 let mut response: Option<ResponseEntry> = None;
371 let mut propstat_props: Vec<PropItem> = Vec::new();
372 let mut propstat_ok: Option<bool> = None;
373
374 loop {
375 match reader.read_event() {
376 Ok(Event::Start(e)) => {
377 let name = local_name(e.local_name().as_ref());
378 if let Some((_, _, children)) = stack.last_mut() {
379 children.push(name.clone());
380 }
381 match name.as_str() {
382 "response" => response = Some(ResponseEntry::default()),
383 "propstat" => {
384 propstat_props.clear();
385 propstat_ok = None;
386 }
387 _ => {}
388 }
389 stack.push((name, String::new(), Vec::new()));
390 }
391 Ok(Event::Empty(e)) => {
392 let name = local_name(e.local_name().as_ref());
393 let parent_is_prop = stack.last().is_some_and(|(n, _, _)| n == "prop");
394 if parent_is_prop {
395 propstat_props.push(PropItem {
396 local: name,
397 ..Default::default()
398 });
399 } else if let Some((_, _, children)) = stack.last_mut() {
400 children.push(name);
401 }
402 }
403 Ok(Event::Text(t)) => {
404 if let Ok(decoded) = t.decode() {
405 if let Some((_, buf, _)) = stack.last_mut() {
406 buf.push_str(&decoded);
407 }
408 }
409 }
410 Ok(Event::GeneralRef(r)) => {
411 if let Some((_, buf, _)) = stack.last_mut() {
412 if let Ok(Some(ch)) = r.resolve_char_ref() {
413 buf.push(ch);
414 } else if let Ok(name) = r.decode() {
415 match name.as_ref() {
416 "amp" => buf.push('&'),
417 "lt" => buf.push('<'),
418 "gt" => buf.push('>'),
419 "quot" => buf.push('"'),
420 "apos" => buf.push('\''),
421 name => {
422 buf.push('&');
424 buf.push_str(name);
425 buf.push(';');
426 }
427 }
428 }
429 }
430 }
431 Ok(Event::CData(t)) => {
432 let bytes = t.into_inner();
433 if let Ok(text) = core::str::from_utf8(&bytes) {
434 if let Some((_, buf, _)) = stack.last_mut() {
435 buf.push_str(text);
436 }
437 }
438 }
439 Ok(Event::End(_)) => {
440 if let Some((name, text, children)) = stack.pop() {
441 let parent = stack.last().map(|(n, _, _)| n.clone());
442 if let Some((_, parent_text, _)) = stack.last_mut() {
443 parent_text.push_str(&text);
444 }
445 let parent = parent.as_deref();
446
447 match name.as_str() {
448 "response" => {
449 if let Some(entry) = response.take() {
450 responses.push(entry);
451 }
452 }
453 "propstat" => {
454 if propstat_ok == Some(true) {
455 if let Some(entry) = response.as_mut() {
456 entry.props.append(&mut propstat_props);
457 }
458 }
459 propstat_props.clear();
460 propstat_ok = None;
461 }
462 "status" if parent == Some("propstat") => {
463 propstat_ok =
464 Some(status_code(&text).is_some_and(|code| code / 100 == 2));
465 }
466 "status" if parent == Some("response") => {
467 if let Some(entry) = response.as_mut() {
468 entry.status = status_code(&text);
469 }
470 }
471 "sync-token" if parent == Some("multistatus") => {
472 let text = text.trim();
473 if !text.is_empty() {
474 sync_token = Some(text.to_string());
475 }
476 }
477 "href" if parent == Some("response") => {
478 if let Some(entry) = response.as_mut() {
479 if entry.href.is_empty() {
480 entry.href = text.trim().to_string();
481 }
482 }
483 }
484 _ if parent == Some("prop") => {
485 propstat_props.push(PropItem {
486 local: name,
487 text,
488 children,
489 });
490 }
491 _ => {}
492 }
493 }
494 }
495 Ok(Event::Eof) | Err(_) => break,
496 _ => {}
497 }
498 }
499
500 Multistatus {
501 responses,
502 sync_token,
503 }
504}
505
506pub fn emit_header(auth: &WebdavAuth) -> Option<String> {
509 match auth {
510 WebdavAuth::None => None,
511 WebdavAuth::Basic(credentials) => Some(credentials.to_authorization()),
512 WebdavAuth::Bearer(token) => Some(token.to_authorization()),
513 }
514}
515
516pub fn resolve(base_url: &Url, path: &str) -> Url {
522 if path.is_empty() {
523 return base_url.clone();
524 }
525
526 if path.starts_with('/') {
527 if let Ok(mut url) = Url::parse(base_url.as_str()) {
528 url.set_path(path);
529 return url;
530 }
531 }
532
533 let mut base = base_url.clone();
534 if !base.path().ends_with('/') {
535 let mut new_path = base.path().to_string();
536 new_path.push('/');
537 base.set_path(&new_path);
538 }
539
540 base.join(path).unwrap_or_else(|_| base_url.clone())
541}
542
543pub fn read_etag(response: &HttpResponse) -> Option<String> {
546 response
547 .header("etag")
548 .map(|raw| raw.trim_matches('"').into())
549}
550
551pub fn resolve_href(base_url: &Url, href: &str) -> Option<Url> {
554 match Url::parse(href) {
555 Ok(url) => Some(url),
556 Err(url::ParseError::RelativeUrlWithoutBase) => base_url.join(href).ok(),
557 Err(_) => None,
558 }
559}
560
561pub fn trace_unrecognized(entry: &ResponseEntry, known: &[Property]) {
565 for item in &entry.props {
566 if !known.iter().any(|prop| prop.local == item.local) {
567 trace!("ignoring unrecognized WebDAV property `{}`", item.local);
568 }
569 }
570}
571
572fn status_code(text: &str) -> Option<u16> {
575 text.split_whitespace().nth(1)?.parse().ok()
576}
577
578fn namespaces(extra: &[Namespace], props: &[Property]) -> Vec<Namespace> {
580 let mut nss = Vec::with_capacity(1 + extra.len() + props.len());
581 nss.push(DAV);
582 nss.extend_from_slice(extra);
583 nss.extend(props.iter().map(|prop| prop.ns));
584 nss
585}
586
587fn qualified(ns: Namespace, local: &str) -> String {
588 if ns.prefix.is_empty() {
589 local.to_string()
590 } else {
591 format!("{}:{local}", ns.prefix)
592 }
593}
594
595fn empty_element(prop: Property) -> String {
596 format!("<{}/>", qualified(prop.ns, prop.local))
597}
598
599fn value_element(prop: Property, value: &str) -> String {
600 let name = qualified(prop.ns, prop.local);
601 format!("<{name}>{}</{name}>", escape_text(value))
602}
603
604fn local_name(bytes: &[u8]) -> String {
605 core::str::from_utf8(bytes).unwrap_or("").to_string()
606}
607#[cfg(test)]
608mod tests {
609 use alloc::string::ToString;
610
611 use io_http::{rfc6750::bearer::HttpAuthBearer, rfc7617::basic::HttpAuthBasic};
612
613 use crate::rfc4918::*;
614
615 const CALDAV: Namespace = Namespace {
616 uri: "urn:ietf:params:xml:ns:caldav",
617 prefix: "C",
618 };
619 const CALENDAR: Property = Property {
620 ns: CALDAV,
621 local: "calendar",
622 };
623 const CALENDAR_DATA: Property = Property {
624 ns: CALDAV,
625 local: "calendar-data",
626 };
627
628 #[test]
629 fn propfind_body_lists_props_with_namespaces() {
630 let body = propfind_body(&[DISPLAYNAME, CALENDAR_DATA]);
631 let xml = core::str::from_utf8(&body).unwrap();
632 assert!(xml.contains("xmlns:D=\"DAV:\""));
633 assert!(xml.contains("xmlns:C=\"urn:ietf:params:xml:ns:caldav\""));
634 assert!(xml.contains("<D:displayname/>"));
635 assert!(xml.contains("<C:calendar-data/>"));
636 }
637
638 #[test]
639 fn mkcol_body_carries_resourcetype_and_values() {
640 let body = mkcol_body(&[CALENDAR], &[(DISPLAYNAME, "Personal & co")]);
641 let xml = core::str::from_utf8(&body).unwrap();
642 assert!(xml.contains("<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>"));
643 assert!(xml.contains("<D:displayname>Personal & co</D:displayname>"));
644 }
645
646 #[test]
647 fn proppatch_body_wraps_values_in_propertyupdate() {
648 let body = proppatch_body(&[(DISPLAYNAME, "Renamed")]);
649 let xml = core::str::from_utf8(&body).unwrap();
650 assert!(xml.contains("<D:propertyupdate xmlns:D=\"DAV:\">"));
651 assert!(
652 xml.contains("<D:set><D:prop><D:displayname>Renamed</D:displayname></D:prop></D:set>")
653 );
654 assert!(xml.ends_with("</D:propertyupdate>"));
655 }
656
657 #[test]
658 fn prop_set_body_roots_at_the_given_element() {
659 const MKCALENDAR: Property = Property {
660 ns: CALDAV,
661 local: "mkcalendar",
662 };
663 let body = prop_set_body(MKCALENDAR, &[(DISPLAYNAME, "Work")]);
664 let xml = core::str::from_utf8(&body).unwrap();
665 assert!(xml.contains("<C:mkcalendar "));
666 assert!(xml.contains("xmlns:C=\"urn:ietf:params:xml:ns:caldav\""));
667 assert!(
668 xml.contains("<D:set><D:prop><D:displayname>Work</D:displayname></D:prop></D:set>")
669 );
670 assert!(xml.ends_with("</C:mkcalendar>"));
671 }
672
673 #[test]
674 fn parse_multistatus_collects_2xx_props() {
675 let xml = r#"<?xml version="1.0"?>
676 <d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
677 <d:response>
678 <d:href>/dav/calendars/personal/</d:href>
679 <d:propstat>
680 <d:prop>
681 <d:displayname>Personal</d:displayname>
682 <d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
683 </d:prop>
684 <d:status>HTTP/1.1 200 OK</d:status>
685 </d:propstat>
686 </d:response>
687 <d:response>
688 <d:href>/dav/calendars/other/</d:href>
689 <d:propstat>
690 <d:prop><d:displayname>Hidden</d:displayname></d:prop>
691 <d:status>HTTP/1.1 404 Not Found</d:status>
692 </d:propstat>
693 </d:response>
694 </d:multistatus>"#;
695
696 let ms = parse_multistatus(xml);
697 assert_eq!(ms.responses.len(), 2);
698
699 let first = &ms.responses[0];
700 assert_eq!(first.id(), "personal");
701 assert_eq!(first.text(DISPLAYNAME), Some("Personal"));
702 assert!(first.has_resource_type(RESOURCETYPE, CALENDAR));
703
704 assert_eq!(ms.responses[1].text(DISPLAYNAME), None);
706 }
707
708 #[test]
709 fn parse_multistatus_reads_sync_collection_rows() {
710 let xml = r#"<?xml version="1.0"?>
711 <d:multistatus xmlns:d="DAV:">
712 <d:response>
713 <d:href>/dav/addressbooks/contacts/changed.vcf</d:href>
714 <d:propstat>
715 <d:prop><d:getetag>"etag-1"</d:getetag></d:prop>
716 <d:status>HTTP/1.1 200 OK</d:status>
717 </d:propstat>
718 </d:response>
719 <d:response>
720 <d:href>/dav/addressbooks/contacts/removed.vcf</d:href>
721 <d:status>HTTP/1.1 404 Not Found</d:status>
722 </d:response>
723 <d:response>
724 <d:href>/dav/addressbooks/contacts/</d:href>
725 <d:status>HTTP/1.1 507 Insufficient Storage</d:status>
726 </d:response>
727 <d:sync-token>http://example.com/ns/sync/1234</d:sync-token>
728 </d:multistatus>"#;
729
730 let ms = parse_multistatus(xml);
731 assert_eq!(
732 ms.sync_token.as_deref(),
733 Some("http://example.com/ns/sync/1234")
734 );
735 assert_eq!(ms.responses.len(), 3);
736
737 let changed = &ms.responses[0];
738 assert_eq!(changed.status, None);
739 assert_eq!(changed.text(GETETAG), Some("\"etag-1\""));
740
741 let removed = &ms.responses[1];
742 assert_eq!(removed.status, Some(404));
743 assert!(removed.props.is_empty());
744
745 let truncated = &ms.responses[2];
746 assert_eq!(truncated.status, Some(507));
747 assert!(truncated.props.is_empty());
748 }
749
750 #[test]
751 fn parse_multistatus_reads_nested_href() {
752 let xml = r#"<d:multistatus xmlns:d="DAV:">
753 <d:response>
754 <d:href>/</d:href>
755 <d:propstat>
756 <d:prop>
757 <d:current-user-principal><d:href>/principals/alice/</d:href></d:current-user-principal>
758 </d:prop>
759 <d:status>HTTP/1.1 200 OK</d:status>
760 </d:propstat>
761 </d:response>
762 </d:multistatus>"#;
763
764 let principal = Property {
765 ns: DAV,
766 local: "current-user-principal",
767 };
768 let ms = parse_multistatus(xml);
769 let entry = &ms.responses[0];
770 assert_eq!(entry.text(principal), Some("/principals/alice/"));
771 }
772
773 #[test]
774 fn none_emits_nothing() {
775 assert!(emit_header(&WebdavAuth::None).is_none());
776 }
777
778 #[test]
779 fn basic_encodes_credentials() {
780 let auth = WebdavAuth::Basic(HttpAuthBasic::new("alice", "secret"));
781 assert_eq!(emit_header(&auth).unwrap(), "Basic YWxpY2U6c2VjcmV0");
783 }
784
785 #[test]
786 fn bearer_prepends_scheme() {
787 let auth = WebdavAuth::Bearer(HttpAuthBearer::new("xyz"));
788 assert_eq!(emit_header(&auth).unwrap(), "Bearer xyz");
789 }
790
791 #[test]
792 fn getetag_uses_the_dav_prefix() {
793 assert_eq!(empty_or(GETETAG), "<D:getetag/>");
794 }
795
796 fn empty_or(prop: Property) -> String {
797 let body = propfind_body(&[prop]);
798 let xml = core::str::from_utf8(&body).unwrap().to_string();
799 let start = xml.find("<D:prop>").unwrap() + "<D:prop>".len();
800 let end = xml.find("</D:prop>").unwrap();
801 xml[start..end].to_string()
802 }
803}