Skip to main content

io_webdav/
rfc4918.rs

1//! RFC 4918: HTTP Extensions for Web Distributed Authoring and
2//! Versioning (WebDAV).
3//!
4//! <https://www.rfc-editor.org/rfc/rfc4918>
5//!
6//! This module carries the WebDAV vocabulary shared across every RFC
7//! layer: the authentication scheme, the namespace and property model,
8//! the generic parsed multistatus body, and the generic `DAV:` property
9//! constants. Alongside them live the crate-internal helpers every
10//! coroutine reuses: the XML request-body generators (PROPFIND,
11//! PROPPATCH, MKCOL, REPORT), the multistatus parser, and the
12//! `Authorization` header emitter, request-path resolution and `ETag`
13//! extraction. Each WebDAV method is its own submodule.
14//!
15//! Request bodies are generated from a [`Property`] selector rather than
16//! hard-coded templates: callers choose the properties and values they
17//! need. Each [`Property`] carries its [`Namespace`] (URI plus preferred
18//! prefix), so the generators emit XML without a central namespace
19//! table; every RFC layer owns the namespaces and property constants it
20//! speaks.
21
22pub 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/// Authentication scheme used by the WebDAV client.
51///
52/// Covers the three modes the CalDAV/CardDAV deployments handle in
53/// practice: no auth, HTTP Basic (RFC 7617) and HTTP Bearer (RFC 6750),
54/// reusing the io-http credential types. Higher-level coroutines never
55/// observe the credential directly; they only see the pre-formatted
56/// header value from `emit_header`.
57#[derive(Clone, Debug, Default)]
58pub enum WebdavAuth {
59    /// No authentication; no `Authorization` header is emitted.
60    #[default]
61    None,
62
63    /// HTTP Basic authentication (RFC 7617).
64    Basic(HttpAuthBasic),
65
66    /// HTTP Bearer authentication (RFC 6750).
67    Bearer(HttpAuthBearer),
68}
69
70/// An XML namespace: its URI plus the preferred prefix used when
71/// serializing request bodies (the empty prefix means the default
72/// namespace).
73///
74/// Each RFC layer owns the namespaces it speaks (`DAV:` in
75/// [`crate::rfc4918`], CalDAV ones in [`crate::rfc4791`], CardDAV ones
76/// in [`crate::rfc6352`]); the generic body generators only read these
77/// fields, so they never need to know which namespaces exist.
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub struct Namespace {
80    /// Namespace URI (e.g. `DAV:`).
81    pub uri: &'static str,
82    /// Preferred XML prefix (`""` for the default namespace).
83    pub prefix: &'static str,
84}
85
86/// A WebDAV property identifier: an XML [`Namespace`] plus a local name
87/// (RFC 4918 §15).
88///
89/// Each RFC layer owns its own vocabulary as `const` values (generic
90/// DAV properties in [`crate::rfc4918`], calendar ones in
91/// [`crate::rfc4791`], card ones in [`crate::rfc6352`]); there is no
92/// central enum. Construct an ad-hoc value for any property the
93/// constants do not cover.
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub struct Property {
96    /// XML namespace.
97    pub ns: Namespace,
98    /// Local element name (e.g. `displayname`).
99    pub local: &'static str,
100}
101
102/// Parsed `multistatus` body returned by `PROPFIND` / `REPORT`
103/// (RFC 4918 §14.16).
104#[derive(Clone, Debug, Default)]
105pub struct Multistatus {
106    /// The parsed `<response>` entries.
107    pub responses: Vec<ResponseEntry>,
108
109    /// The top-level `DAV:sync-token` returned by a `sync-collection`
110    /// REPORT (RFC 6578 §6.2); [`None`] outside sync responses.
111    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/// A single `<response>` inside a [`Multistatus`]: its `href` plus the
124/// properties returned under 2xx `propstat`s.
125#[derive(Clone, Debug, Default)]
126pub struct ResponseEntry {
127    /// The `<href>` text, as returned by the server.
128    pub href: String,
129    /// The response-level `<status>` code, when present. Carries the
130    /// 404 of a `sync-collection` removal row (RFC 6578 §3.4) or the
131    /// 507 of a truncation row (RFC 6578 §3.6); [`None`] on ordinary
132    /// propstat-only responses.
133    pub status: Option<u16>,
134    /// Properties gathered from every 2xx `<propstat>` of this response.
135    pub props: Vec<PropItem>,
136}
137
138impl ResponseEntry {
139    /// Returns the property matching `prop` (by local name), if present.
140    pub fn prop(&self, prop: Property) -> Option<&PropItem> {
141        self.props.iter().find(|item| item.local == prop.local)
142    }
143
144    /// Returns `prop`'s trimmed text content when present and non-empty.
145    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    /// Returns `true` when `<resourcetype>` lists `ty` as a child
152    /// (e.g. `<C:calendar/>`).
153    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    /// Returns the last non-empty path segment of [`href`](Self::href),
159    /// the conventional collection / resource identifier.
160    pub fn id(&self) -> &str {
161        self.href
162            .trim_end_matches('/')
163            .rsplit('/')
164            .next()
165            .unwrap_or("")
166    }
167}
168
169/// A single property returned inside a `<prop>` element.
170#[derive(Clone, Debug, Default)]
171pub struct PropItem {
172    /// Property local name (e.g. `displayname`, `resourcetype`).
173    pub local: String,
174    /// Concatenated descendant text (covers text properties and the
175    /// `<href>` payload of principal / home-set properties).
176    pub text: String,
177    /// Local names of the direct child elements (e.g. `calendar`,
178    /// `collection` under `<resourcetype>`).
179    pub children: Vec<String>,
180}
181
182/// WebDAV namespace (RFC 4918), emitted with the `D` prefix the RFC
183/// examples use. Never the default namespace: strict servers (iCloud,
184/// Google) reject bodies mixing a prefixed CardDAV root with
185/// default-namespace DAV children (their addressbook-multiget answers
186/// HTTP 400), while the all-prefixed form every interoperable client
187/// sends passes everywhere. The literal `D:` in the body generators
188/// assumes this prefix.
189pub const DAV: Namespace = Namespace {
190    uri: "DAV:",
191    prefix: "D",
192};
193/// CalendarServer extension namespace (ctag); protocol-neutral, used by
194/// both CalDAV and CardDAV servers.
195pub const CALENDARSERVER: Namespace = Namespace {
196    uri: "http://calendarserver.org/ns/",
197    prefix: "CS",
198};
199
200/// Standard XML declaration prepended to every request body.
201pub const XML_DECL: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>";
202
203/// `DAV:displayname` (RFC 4918 §15.2).
204pub const DISPLAYNAME: Property = Property {
205    ns: DAV,
206    local: "displayname",
207};
208/// `DAV:resourcetype` (RFC 4918 §15.9).
209pub const RESOURCETYPE: Property = Property {
210    ns: DAV,
211    local: "resourcetype",
212};
213/// `DAV:getetag` (RFC 4918 §15.6).
214pub const GETETAG: Property = Property {
215    ns: DAV,
216    local: "getetag",
217};
218/// `DAV:sync-token` (RFC 6578 §4), the collection checkpoint property.
219pub const SYNC_TOKEN: Property = Property {
220    ns: DAV,
221    local: "sync-token",
222};
223/// `CS:getctag` (CalendarServer extension); bumped on every change to
224/// the collection.
225pub const GETCTAG: Property = Property {
226    ns: CALENDARSERVER,
227    local: "getctag",
228};
229
230/// `DAV:propertyupdate` PROPPATCH request root (RFC 4918 §9.2).
231const PROPERTYUPDATE: Property = Property {
232    ns: DAV,
233    local: "propertyupdate",
234};
235
236/// Emits the `xmlns` declarations for the given namespaces (deduped by
237/// URI, in order). The empty-prefix namespace becomes the default
238/// namespace.
239pub 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
259/// Escapes XML text content (`&`, `<`, `>`).
260pub fn escape_text(text: &str) -> String {
261    text.replace('&', "&amp;")
262        .replace('<', "&lt;")
263        .replace('>', "&gt;")
264}
265
266/// Emits a `D:prop` block listing each property as an empty element.
267pub 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
276/// Builds a `PROPFIND` request body (RFC 4918 §9.1) requesting `props`.
277pub 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
285/// Builds a `PROPPATCH` request body (RFC 4918 §9.2) setting each
286/// `(property, value)` pair.
287pub fn proppatch_body(set: &[(Property, &str)]) -> Vec<u8> {
288    prop_set_body(PROPERTYUPDATE, set)
289}
290
291/// Builds a `<root><set><prop>...</prop></set></root>` body setting each
292/// `(property, value)` pair, rooted at `root`. Backs both
293/// [`proppatch_body`] (`DAV:propertyupdate`) and CalDAV `MKCALENDAR`
294/// (`C:mkcalendar`, RFC 4791 §5.3.1).
295pub 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
310/// Builds an extended `MKCOL` request body (RFC 5689 §3): a
311/// `<resourcetype>` of `<collection/>` plus `resource_types`, and each
312/// `set` property value.
313pub 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
331/// Builds a `REPORT` query body (RFC 3253 §3.6) rooted at `root` (e.g.
332/// `calendar-query`), requesting `props` and appending the raw `filter`
333/// fragment. `extra_ns` declares namespaces the filter needs beyond
334/// those of `root` and `props`.
335pub 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
354/// Parses a `multistatus` body into vocabulary-agnostic entries.
355///
356/// Matching is by local name (namespace prefixes are ignored), and only
357/// properties under 2xx `propstat`s are kept. Responses without any 2xx
358/// propstat still survive as entries with empty props, carrying their
359/// response-level status (`sync-collection` removal and truncation
360/// rows). Predefined and numeric character references are resolved;
361/// unknown entity references are kept verbatim. Malformed input yields
362/// whatever was parsed before the error.
363pub 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    // (local name, accumulated descendant text, direct child names)
369    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                                // NOTE: unknown entity, kept verbatim.
423                                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
506/// Returns the value of the HTTP `Authorization` header for the given
507/// scheme, or [`None`] when no header should be emitted.
508pub 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
516/// Resolves `path` against `base_url`.
517///
518/// Empty paths return `base_url` unchanged. Absolute paths (starting
519/// with `/`) replace the base path. Relative paths are appended to the
520/// base path. Falls back to `base_url` when the join fails.
521pub 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
543/// Reads the `ETag` header (RFC 9110 §8.8.3) out of an HTTP response,
544/// stripping the surrounding double quotes when present.
545pub fn read_etag(response: &HttpResponse) -> Option<String> {
546    response
547        .header("etag")
548        .map(|raw| raw.trim_matches('"').into())
549}
550
551/// Resolves an `<href>` value against `base_url`, joining when the href
552/// is relative. Returns [`None`] when the href cannot be parsed.
553pub 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
561/// Trace-logs every property of `entry` whose local name is not in
562/// `known`. Lets `from_props` mappers surface ignored properties
563/// without failing.
564pub 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
572/// Extracts the numeric code out of an HTTP status line
573/// (e.g. `HTTP/1.1 404 Not Found`).
574fn status_code(text: &str) -> Option<u16> {
575    text.split_whitespace().nth(1)?.parse().ok()
576}
577
578/// Collects `DAV:` plus `extra` plus every property namespace.
579fn 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 &amp; 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        // 404 propstat is ignored
705        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        // NOTE: base64("alice:secret") = "YWxpY2U6c2VjcmV0"
782        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}