1use sha2::{Digest, Sha256};
5
6#[derive(Debug, Clone)]
10pub struct DavResponse {
11 pub status: u16,
13 pub headers: Vec<(String, String)>,
17 pub body: Vec<u8>,
20}
21
22impl DavResponse {
23 pub fn new(status: u16) -> Self {
25 Self {
26 status,
27 headers: Vec::new(),
28 body: Vec::new(),
29 }
30 }
31
32 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 pub fn with_body(mut self, body: Vec<u8>) -> Self {
41 self.body = body;
42 self
43 }
44}
45
46pub fn etag_of(data: &str) -> String {
50 let hash = Sha256::digest(data.as_bytes());
51 hex::encode(&hash[..8])
52}
53
54pub fn xml_escape(s: &str) -> String {
59 s.replace('&', "&")
60 .replace('<', "<")
61 .replace('>', ">")
62 .replace('"', """)
63}
64
65pub 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
82pub 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<b>c&d"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}