Skip to main content

nextcloud_dav/
resource.rs

1//! One `<d:response>` turned into a typed resource.
2
3use crate::propfind::DavProperty;
4use crate::xml::response::{has_element, text_of};
5use crate::xml::{Element, MissingElement};
6
7/// One entry of a multistatus answer.
8///
9/// Optional fields stay `None` both when the server omitted the property and
10/// when it sent a value this crate could not read; the two are not
11/// distinguished, so a caller must treat `None` as "unknown", never as
12/// "false" or "zero".
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct DavResource {
15    /// Path of the resource as the server reported it, usually
16    /// percent-encoded and including `/remote.php/dav`.
17    pub href: String,
18    /// Whether the resource is a collection (`<d:collection/>`).
19    pub is_collection: bool,
20    /// `d:getetag`, verbatim including any quoting the server sent.
21    pub etag: Option<String>,
22    /// `d:getcontentlength`, in bytes.
23    pub content_length: Option<u64>,
24    /// `d:getcontenttype`.
25    pub content_type: Option<String>,
26    /// `oc:fileid`.
27    pub file_id: Option<String>,
28    /// `nc:has-preview`.
29    pub has_preview: Option<bool>,
30}
31
32impl DavResource {
33    /// Builds a resource from one `<d:response>` element.
34    ///
35    /// Only `d:href` is required: without it the entry does not identify
36    /// anything. Property values are read from every `<d:propstat>` whose
37    /// status is `200` (or that carries no status at all), because RFC 4918
38    /// lets a server split requested properties across several propstat
39    /// blocks by outcome.
40    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
75/// A propstat without a status is treated as successful, which is what the
76/// specification's default (200) means; anything not `200` carries a
77/// `<d:responsedescription>` instead of usable values.
78fn 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    /// Renders the href and whether the entry is a collection, which is all
94    /// that is worth putting in a log line.
95    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}