nextcloud_dav/
resource.rs1use crate::propfind::DavProperty;
4use crate::xml::response::{has_element, text_of};
5use crate::xml::{Element, MissingElement};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct DavResource {
15 pub href: String,
18 pub is_collection: bool,
20 pub etag: Option<String>,
22 pub content_length: Option<u64>,
24 pub content_type: Option<String>,
26 pub file_id: Option<String>,
28 pub has_preview: Option<bool>,
30}
31
32impl DavResource {
33 pub(crate) fn from_response(response: &Element<'_>) -> Result<Self, MissingElement> {
41 let href = response
42 .child("d:href")
43 .map(|element| element.text())
44 .filter(|href| !href.is_empty())
45 .ok_or_else(|| MissingElement::new("d:href"))?;
46
47 let props: Vec<Element<'_>> = response
48 .children("d:propstat")
49 .into_iter()
50 .filter(is_successful_propstat)
51 .filter_map(|propstat| propstat.child("d:prop"))
52 .collect();
53
54 let find = |property: DavProperty| {
55 props
56 .iter()
57 .find_map(|prop| text_of(prop.inner(), property.qualified_name()))
58 };
59
60 Ok(Self {
61 href,
62 is_collection: props
63 .iter()
64 .any(|prop| has_element(prop.inner(), "d:collection")),
65 etag: find(DavProperty::Etag),
66 content_length: find(DavProperty::ContentLength)
67 .and_then(|value| value.trim().parse().ok()),
68 content_type: find(DavProperty::ContentType),
69 file_id: find(DavProperty::FileId),
70 has_preview: find(DavProperty::HasPreview).map(|value| is_truthy(value.as_str())),
71 })
72 }
73}
74
75fn is_successful_propstat(propstat: &Element<'_>) -> bool {
79 match propstat.child("d:status") {
80 None => true,
81 Some(status) => status.text().contains(" 200 "),
82 }
83}
84
85fn is_truthy(value: &str) -> bool {
86 matches!(
87 value.trim().to_ascii_lowercase().as_str(),
88 "1" | "true" | "yes" | "on"
89 )
90}
91
92impl std::fmt::Display for DavResource {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 let kind = if self.is_collection {
97 "collection"
98 } else {
99 "file"
100 };
101 write!(f, "{} ({kind})", self.href)
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::DavResource;
108 use crate::xml::response::elements;
109
110 const FILE_RESPONSE: &str = r#"<d:response>
111 <d:href>/remote.php/dav/files/alice/Q1%20%26%20Q2.txt</d:href>
112 <d:propstat>
113 <d:prop>
114 <d:getetag>"abc123"</d:getetag>
115 <d:getcontentlength>42</d:getcontentlength>
116 <d:getcontenttype>text/plain</d:getcontenttype>
117 <d:resourcetype/>
118 <oc:fileid>17</oc:fileid>
119 <nc:has-preview>false</nc:has-preview>
120 </d:prop>
121 <d:status>HTTP/1.1 200 OK</d:status>
122 </d:propstat>
123 </d:response>"#;
124
125 const COLLECTION_RESPONSE: &str = r#"<d:response>
126 <d:href>/remote.php/dav/files/alice/Photos</d:href>
127 <d:propstat>
128 <d:prop>
129 <d:resourcetype><d:collection/></d:resourcetype>
130 <oc:fileid>9</oc:fileid>
131 <nc:has-preview>true</nc:has-preview>
132 </d:prop>
133 <d:status>HTTP/1.1 200 OK</d:status>
134 </d:propstat>
135 </d:response>"#;
136
137 fn single(xml: &str) -> DavResource {
138 let response = elements(xml, "d:response").remove(0);
139 DavResource::from_response(&response).expect("resource decodes")
140 }
141
142 #[test]
143 fn file_response_keeps_every_property() {
144 let resource = single(FILE_RESPONSE);
145 assert_eq!(
146 resource.href,
147 "/remote.php/dav/files/alice/Q1%20%26%20Q2.txt"
148 );
149 assert!(!resource.is_collection);
150 assert_eq!(resource.etag.as_deref(), Some("\"abc123\""));
151 assert_eq!(resource.content_length, Some(42));
152 assert_eq!(resource.content_type.as_deref(), Some("text/plain"));
153 assert_eq!(resource.file_id.as_deref(), Some("17"));
154 assert_eq!(resource.has_preview, Some(false));
155 }
156
157 #[test]
158 fn collection_response_is_marked_as_such() {
159 let resource = single(COLLECTION_RESPONSE);
160 assert!(resource.is_collection);
161 assert_eq!(resource.has_preview, Some(true));
162 assert_eq!(resource.content_length, None);
163 }
164
165 #[test]
166 fn unreadable_values_are_omitted_not_guessed() {
167 let xml = r#"<d:response><d:href>/a</d:href><d:propstat><d:prop>
168 <d:getcontentlength>not-a-number</d:getcontentlength>
169 </d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"#;
170 assert_eq!(single(xml).content_length, None);
171 }
172
173 #[test]
174 fn failed_propstat_contributes_nothing() {
175 let xml = r#"<d:response><d:href>/a</d:href>
176 <d:propstat><d:prop><d:getetag>"no"</d:getetag></d:prop>
177 <d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>
178 </d:response>"#;
179 assert_eq!(single(xml).etag, None);
180 }
181
182 #[test]
183 fn response_without_href_is_rejected() {
184 let response = elements("<d:response><d:propstat/></d:response>", "d:response").remove(0);
185 let error = DavResource::from_response(&response).expect_err("href is mandatory");
186 assert_eq!(error.name(), "d:href");
187 }
188
189 #[test]
190 fn display_mentions_kind_and_href() {
191 assert_eq!(
192 single(COLLECTION_RESPONSE).to_string(),
193 "/remote.php/dav/files/alice/Photos (collection)"
194 );
195 }
196}