Skip to main content

mailrs_dav/
xml.rs

1//! Framework-agnostic HTTP response representation plus the small XML helpers
2//! that DAV handlers compose multistatus / propstat bodies from.
3
4use sha2::{Digest, Sha256};
5
6/// A minimal HTTP response shape. Handlers return this; server-side wrapper
7/// code (axum / actix / hyper) translates it into the framework's own response
8/// type.
9#[derive(Debug, Clone)]
10pub struct DavResponse {
11    /// HTTP status code (e.g. `200`, `207`, `404`).
12    pub status: u16,
13    /// Response headers as `(name, value)` pairs, stored verbatim. The
14    /// server-side wrapper is expected to canonicalise header names if its
15    /// framework requires it.
16    pub headers: Vec<(String, String)>,
17    /// Response body bytes. Typically XML for multistatus, plain text for
18    /// errors, raw iCalendar / vCard for GET on a resource.
19    pub body: Vec<u8>,
20}
21
22impl DavResponse {
23    /// Empty response with `status` and no headers / body.
24    pub fn new(status: u16) -> Self {
25        Self {
26            status,
27            headers: Vec::new(),
28            body: Vec::new(),
29        }
30    }
31
32    /// Builder: append a header. Both `name` and `value` are stored verbatim;
33    /// header-name canonicalisation is the server-side wrapper's job.
34    pub fn with_header(mut self, name: &str, value: &str) -> Self {
35        self.headers.push((name.to_string(), value.to_string()));
36        self
37    }
38
39    /// Builder: set the body.
40    pub fn with_body(mut self, body: Vec<u8>) -> Self {
41        self.body = body;
42        self
43    }
44}
45
46/// Short, stable ETag derived from a piece of content. Implemented as the
47/// first 8 bytes of SHA-256 hex-encoded (16 ASCII chars), matching the
48/// mailrs reference implementation.
49pub fn etag_of(data: &str) -> String {
50    let hash = Sha256::digest(data.as_bytes());
51    hex::encode(&hash[..8])
52}
53
54/// XML-escape a text-content string for inclusion in an XML body.
55///
56/// Covers the five entities required by XML 1.0 plus the double quote
57/// (needed inside attribute values that some clients emit).
58pub fn xml_escape(s: &str) -> String {
59    s.replace('&', "&amp;")
60        .replace('<', "&lt;")
61        .replace('>', "&gt;")
62        .replace('"', "&quot;")
63}
64
65/// Build a `207 Multi-Status` response with the DAV namespaces declared and
66/// `inner` wrapped inside `<D:multistatus>...</D:multistatus>`.
67pub fn multistatus(inner: &str) -> DavResponse {
68    let body = format!(
69        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
70         <D:multistatus xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" \
71         xmlns:CR=\"urn:ietf:params:xml:ns:carddav\" \
72         xmlns:CS=\"http://calendarserver.org/ns/\">\n\
73         {inner}\n\
74         </D:multistatus>"
75    );
76    DavResponse::new(207)
77        .with_header("content-type", "application/xml; charset=utf-8")
78        .with_header("dav", "1, 2, 3, calendar-access, addressbook")
79        .with_body(body.into_bytes())
80}
81
82/// Canonical OPTIONS response advertising CalDAV + CardDAV class support and
83/// the verbs handlers in this crate understand.
84pub fn options_response() -> DavResponse {
85    DavResponse::new(200)
86        .with_header("dav", "1, 2, 3, calendar-access, addressbook")
87        .with_header(
88            "allow",
89            "OPTIONS, GET, PUT, DELETE, PROPFIND, REPORT, MKCALENDAR",
90        )
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn etag_is_deterministic() {
99        let a = etag_of("hello world");
100        let b = etag_of("hello world");
101        assert_eq!(a, b);
102    }
103
104    #[test]
105    fn etag_is_16_hex_chars() {
106        let etag = etag_of("anything");
107        assert_eq!(etag.len(), 16);
108        assert!(etag.chars().all(|c| c.is_ascii_hexdigit()));
109    }
110
111    #[test]
112    fn different_content_yields_different_etag() {
113        assert_ne!(etag_of("a"), etag_of("b"));
114    }
115
116    #[test]
117    fn xml_escape_covers_required_entities() {
118        assert_eq!(xml_escape("a<b>c&d\"e"), "a&lt;b&gt;c&amp;d&quot;e");
119    }
120
121    #[test]
122    fn xml_escape_passes_plain_text_through() {
123        assert_eq!(xml_escape("just text"), "just text");
124    }
125
126    #[test]
127    fn multistatus_wraps_body_with_namespaces() {
128        let resp = multistatus("<D:response/>");
129        assert_eq!(resp.status, 207);
130        let body = String::from_utf8(resp.body).unwrap();
131        assert!(body.contains("xmlns:D=\"DAV:\""));
132        assert!(body.contains("xmlns:C=\"urn:ietf:params:xml:ns:caldav\""));
133        assert!(body.contains("<D:response/>"));
134    }
135
136    #[test]
137    fn options_response_advertises_dav_classes() {
138        let resp = options_response();
139        assert_eq!(resp.status, 200);
140        let dav = resp
141            .headers
142            .iter()
143            .find(|(k, _)| k.eq_ignore_ascii_case("dav"))
144            .map(|(_, v)| v.as_str())
145            .unwrap();
146        assert!(dav.contains("calendar-access"));
147        assert!(dav.contains("addressbook"));
148    }
149
150    #[test]
151    fn dav_response_builder_appends_headers() {
152        let resp = DavResponse::new(200)
153            .with_header("content-type", "text/calendar")
154            .with_header("etag", "\"abc\"");
155        assert_eq!(resp.headers.len(), 2);
156        assert_eq!(resp.headers[0].0, "content-type");
157        assert_eq!(resp.headers[1].1, "\"abc\"");
158    }
159}