Skip to main content

nextcloud_dav/client/
dav_client.rs

1//! Request plumbing for WebDAV calls.
2
3use nextcloud_sdk_core::{
4    AuthProvider, DecodeError, Error, Headers, HttpMethod, Request, Response, StatusCode,
5    StatusError, Transport,
6};
7
8use super::multistatus;
9use crate::method::{DEPTH_HEADER, DESTINATION_HEADER, Depth, OVERWRITE_HEADER, Overwrite};
10use crate::path::DavPath;
11use crate::propfind::PropfindBody;
12use crate::resource::DavResource;
13
14/// MIME type every WebDAV body in this crate is sent with.
15const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
16
17/// WebDAV client generic over the transport and the credential source.
18///
19/// Both are held by reference, so a client can be borrowed into several
20/// spawned tasks and share one connection pool and one token.
21#[derive(Clone, Copy, Debug)]
22pub struct DavClient<'a, T: Transport, A: AuthProvider> {
23    transport: &'a T,
24    auth: &'a A,
25}
26
27impl<'a, T: Transport, A: AuthProvider> DavClient<'a, T, A> {
28    /// Binds a client to a transport and a credential source.
29    pub const fn new(transport: &'a T, auth: &'a A) -> Self {
30        Self { transport, auth }
31    }
32
33    /// Transport this client sends through.
34    pub const fn transport(&self) -> &'a T {
35        self.transport
36    }
37
38    /// Credential source applied to every request.
39    pub const fn auth(&self) -> &'a A {
40        self.auth
41    }
42
43    /// Asks `path` for the properties in `body`, `depth` levels deep.
44    ///
45    /// The answer must be `207 Multi-Status`; any other status becomes
46    /// [`Error::Status`] with the response body retained for diagnosis. A
47    /// body that is not a multistatus becomes [`Error::Decode`].
48    pub async fn propfind(
49        &self,
50        path: &DavPath,
51        depth: Depth,
52        body: &PropfindBody,
53    ) -> Result<Vec<DavResource>, Error> {
54        let request = self.build_propfind(path, depth, body);
55        let response = self.transport.execute(request).await?;
56
57        if response.status() != StatusCode::new(207) {
58            return Err(Error::Status(StatusError::new(
59                HttpMethod::Propfind.as_str(),
60                path.to_api_path(),
61                response.status(),
62                response.body(),
63            )));
64        }
65
66        let text = std::str::from_utf8(response.body())
67            .map_err(|error| Error::Decode(DecodeError::new(multistatus::TARGET, error)))?;
68        multistatus::decode(text).map_err(Error::Decode)
69    }
70
71    /// Downloads the content of the resource at `path`.
72    ///
73    /// Any non-`2xx` status returns [`Error::Status`] with the response body
74    /// retained for diagnosis.
75    pub async fn get(&self, path: &DavPath) -> Result<Vec<u8>, Error> {
76        let headers = self.authenticated_headers();
77        let request = Request::new(HttpMethod::Get, path.to_api_path()).with_headers(headers);
78        let response = self.transport.execute(request).await?;
79
80        ensure_success(HttpMethod::Get, path, &response)?;
81        Ok(response.into_body())
82    }
83
84    /// Uploads `body` to `path`, optionally specifying the `Content-Type` header.
85    ///
86    /// Any non-`2xx` status returns [`Error::Status`] with the response body
87    /// retained for diagnosis.
88    pub async fn put(
89        &self,
90        path: &DavPath,
91        body: impl Into<Vec<u8>>,
92        content_type: Option<&str>,
93    ) -> Result<(), Error> {
94        let mut headers = self.authenticated_headers();
95        if let Some(content_type) = content_type {
96            headers.set("Content-Type", content_type);
97        }
98        let request = Request::new(HttpMethod::Put, path.to_api_path())
99            .with_headers(headers)
100            .body_bytes(body);
101        let response = self.transport.execute(request).await?;
102
103        ensure_success(HttpMethod::Put, path, &response)
104    }
105
106    /// Creates a collection (folder) at `path`.
107    ///
108    /// Any non-`2xx` status returns [`Error::Status`] with the response body
109    /// retained for diagnosis.
110    pub async fn mkcol(&self, path: &DavPath) -> Result<(), Error> {
111        let headers = self.authenticated_headers();
112        let request = Request::new(HttpMethod::Mkcol, path.to_api_path()).with_headers(headers);
113        let response = self.transport.execute(request).await?;
114
115        ensure_success(HttpMethod::Mkcol, path, &response)
116    }
117
118    /// Deletes the resource or collection at `path`.
119    ///
120    /// Any non-`2xx` status returns [`Error::Status`] with the response body
121    /// retained for diagnosis.
122    pub async fn delete(&self, path: &DavPath) -> Result<(), Error> {
123        let headers = self.authenticated_headers();
124        let request = Request::new(HttpMethod::Delete, path.to_api_path()).with_headers(headers);
125        let response = self.transport.execute(request).await?;
126
127        ensure_success(HttpMethod::Delete, path, &response)
128    }
129
130    /// Copies a resource from `source` to `destination`.
131    ///
132    /// When `overwrite` is `true`, an existing destination is replaced;
133    /// otherwise the call fails with a status error if the destination exists.
134    /// Any non-`2xx` status returns [`Error::Status`] with the response body
135    /// retained for diagnosis.
136    pub async fn copy(
137        &self,
138        source: &DavPath,
139        destination: &DavPath,
140        overwrite: bool,
141    ) -> Result<(), Error> {
142        self.transfer(HttpMethod::Copy, source, destination, overwrite)
143            .await
144    }
145
146    /// Moves a resource from `source` to `destination`.
147    ///
148    /// When `overwrite` is `true`, an existing destination is replaced;
149    /// otherwise the call fails with a status error if the destination exists.
150    /// Any non-`2xx` status returns [`Error::Status`] with the response body
151    /// retained for diagnosis.
152    pub async fn move_(
153        &self,
154        source: &DavPath,
155        destination: &DavPath,
156        overwrite: bool,
157    ) -> Result<(), Error> {
158        self.transfer(HttpMethod::Move, source, destination, overwrite)
159            .await
160    }
161
162    fn authenticated_headers(&self) -> Headers {
163        // Credentials go on first: the headers set afterwards can then never
164        // displace an `Authorization` the provider applied.
165        let mut headers = Headers::new();
166        self.auth.authenticate(&mut headers);
167        headers
168    }
169
170    async fn transfer(
171        &self,
172        method: HttpMethod,
173        source: &DavPath,
174        destination: &DavPath,
175        overwrite: bool,
176    ) -> Result<(), Error> {
177        let mut headers = self.authenticated_headers();
178        headers.set(DESTINATION_HEADER, destination.to_api_path().as_str());
179        headers.set(
180            OVERWRITE_HEADER,
181            Overwrite::from(overwrite).as_header_value(),
182        );
183        let request = Request::new(method, source.to_api_path()).with_headers(headers);
184        let response = self.transport.execute(request).await?;
185
186        ensure_success(method, source, &response)
187    }
188
189    fn build_propfind(&self, path: &DavPath, depth: Depth, body: &PropfindBody) -> Request {
190        let mut headers = self.authenticated_headers();
191        headers.set(DEPTH_HEADER, depth.as_header_value());
192        headers.set("Content-Type", XML_CONTENT_TYPE);
193
194        Request::new(HttpMethod::Propfind, path.to_api_path())
195            .with_headers(headers)
196            .body_bytes(body.to_xml().into_bytes())
197    }
198}
199
200fn ensure_success(method: HttpMethod, path: &DavPath, response: &Response) -> Result<(), Error> {
201    if !response.status().is_success() {
202        return Err(Error::Status(StatusError::new(
203            method.as_str(),
204            path.to_api_path(),
205            response.status(),
206            response.body(),
207        )));
208    }
209    Ok(())
210}
211
212impl<T: Transport, A: AuthProvider> std::fmt::Display for DavClient<'_, T, A> {
213    /// A client carries no identifying state worth logging.
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.write_str("DavClient")
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use std::future::Future;
222    use std::sync::Mutex;
223    use std::task::Poll;
224
225    use nextcloud_sdk_core::{
226        AuthProvider, Error, Headers, HttpMethod, Request, Response, StatusCode, Transport,
227    };
228
229    use super::{DavClient, XML_CONTENT_TYPE};
230    use crate::fixtures::TWO_ENTRIES;
231    use crate::method::{DEPTH_HEADER, DESTINATION_HEADER, Depth, OVERWRITE_HEADER};
232    use crate::path::DavPath;
233    use crate::propfind::PropfindBody;
234    use crate::resource::DavResource;
235
236    /// Answers every request with one canned response and records what it was
237    /// asked, so header and body construction are assertable without a server.
238    struct FakeTransport {
239        status: StatusCode,
240        body: Vec<u8>,
241        seen: Mutex<Vec<Request>>,
242    }
243
244    impl FakeTransport {
245        fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
246            Self {
247                status: StatusCode::new(status),
248                body: body.into(),
249                seen: Mutex::new(Vec::new()),
250            }
251        }
252
253        fn last_request(&self) -> Request {
254            let seen = self.seen.lock().expect("test mutex is not poisoned");
255            seen.last().cloned().expect("a request was sent")
256        }
257    }
258
259    impl Transport for FakeTransport {
260        async fn execute(&self, request: Request) -> Result<Response, Error> {
261            self.seen
262                .lock()
263                .expect("test mutex is not poisoned")
264                .push(request);
265            Ok(Response::new(
266                self.status,
267                Headers::new(),
268                self.body.clone(),
269            ))
270        }
271    }
272
273    struct FakeAuth;
274
275    impl AuthProvider for FakeAuth {
276        fn authenticate(&self, headers: &mut Headers) {
277            headers.set("Authorization", "Basic dGVzdDp0ZXN0");
278        }
279    }
280
281    fn run<F: Future>(future: F) -> F::Output {
282        let mut future = std::pin::pin!(future);
283        let waker = std::task::Waker::noop();
284        let mut context = std::task::Context::from_waker(waker);
285        loop {
286            if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
287                return value;
288            }
289        }
290    }
291
292    fn propfind(status: u16, body: &str) -> (Result<Vec<DavResource>, Error>, FakeTransport) {
293        let transport = FakeTransport::new(status, body);
294        let auth = FakeAuth;
295        let client = DavClient::new(&transport, &auth);
296        let path = DavPath::new("alice", "Photos").expect("path is valid");
297        let result = run(client.propfind(&path, Depth::One, &PropfindBody::all()));
298        (result, transport)
299    }
300
301    #[test]
302    fn propfind_sets_depth_content_type_and_auth() {
303        let (result, transport) = propfind(207, TWO_ENTRIES);
304        assert!(result.is_ok());
305
306        let request = transport.last_request();
307        assert_eq!(request.method(), HttpMethod::Propfind);
308        assert_eq!(
309            request.path().as_str(),
310            "/remote.php/dav/files/alice/Photos"
311        );
312        assert_eq!(request.headers().get(DEPTH_HEADER), Some("1"));
313        assert_eq!(
314            request.headers().get("Content-Type"),
315            Some(XML_CONTENT_TYPE)
316        );
317        assert_eq!(
318            request.headers().get("Authorization"),
319            Some("Basic dGVzdDp0ZXN0")
320        );
321    }
322
323    #[test]
324    fn propfind_sends_the_rendered_body() {
325        let (_, transport) = propfind(207, TWO_ENTRIES);
326        let request = transport.last_request();
327        let body = std::str::from_utf8(request.body().expect("body")).expect("utf-8");
328        assert!(body.contains("<d:propfind"));
329        assert!(body.contains(r#"xmlns:d="DAV:""#));
330        assert!(body.contains("<d:getetag/>"));
331    }
332
333    #[test]
334    fn depth_token_reaches_the_header() {
335        for (depth, expected) in [
336            (Depth::Zero, "0"),
337            (Depth::One, "1"),
338            (Depth::Infinity, "infinity"),
339        ] {
340            let transport = FakeTransport::new(207, TWO_ENTRIES);
341            let auth = FakeAuth;
342            let client = DavClient::new(&transport, &auth);
343            let path = DavPath::root("alice").expect("path is valid");
344            let _ = run(client.propfind(&path, depth, &PropfindBody::all()));
345            assert_eq!(
346                transport.last_request().headers().get(DEPTH_HEADER),
347                Some(expected)
348            );
349        }
350    }
351
352    #[test]
353    fn successful_propfind_decodes_the_resources() {
354        let (result, _) = propfind(207, TWO_ENTRIES);
355        let resources = result.expect("decode succeeds");
356        assert_eq!(resources.len(), 2);
357        assert!(resources[0].is_collection);
358        assert!(!resources[1].is_collection);
359    }
360
361    #[test]
362    fn non_multistatus_status_becomes_a_status_error() {
363        let (result, _) = propfind(404, "<d:error/>");
364        let error = result.expect_err("404 is not a multistatus");
365        assert_eq!(error.status_code(), Some(404));
366        assert!(error.is_not_found());
367    }
368
369    #[test]
370    fn unreadable_body_becomes_a_decode_error() {
371        let (result, _) = propfind(207, "<html>not dav</html>");
372        let error = result.expect_err("body is not a multistatus");
373        let decode = error.as_decode().expect("decode stage");
374        assert_eq!(decode.target(), "DavResource");
375    }
376
377    #[test]
378    fn invalid_utf8_body_becomes_a_decode_error() {
379        let transport = FakeTransport::new(207, vec![0xff, 0xfe]);
380        let auth = FakeAuth;
381        let client = DavClient::new(&transport, &auth);
382        let path = DavPath::root("alice").expect("path is valid");
383        let error = run(client.propfind(&path, Depth::Zero, &PropfindBody::all()))
384            .expect_err("invalid utf-8");
385        assert_eq!(error.stage().as_str(), "decode");
386    }
387
388    #[test]
389    fn get_downloads_content_and_sets_auth() {
390        let transport = FakeTransport::new(200, b"file payload");
391        let auth = FakeAuth;
392        let client = DavClient::new(&transport, &auth);
393        let path = DavPath::new("alice", "document.txt").expect("path is valid");
394
395        let body = run(client.get(&path)).expect("get succeeds");
396        assert_eq!(body, b"file payload");
397
398        let request = transport.last_request();
399        assert_eq!(request.method(), HttpMethod::Get);
400        assert_eq!(
401            request.path().as_str(),
402            "/remote.php/dav/files/alice/document.txt"
403        );
404        assert_eq!(
405            request.headers().get("Authorization"),
406            Some("Basic dGVzdDp0ZXN0")
407        );
408    }
409
410    #[test]
411    fn get_non_success_becomes_a_status_error() {
412        let transport = FakeTransport::new(404, "not found");
413        let auth = FakeAuth;
414        let client = DavClient::new(&transport, &auth);
415        let path = DavPath::new("alice", "missing.txt").expect("path is valid");
416
417        let error = run(client.get(&path)).expect_err("404 returns error");
418        assert_eq!(error.status_code(), Some(404));
419        let status = error.as_status().expect("status error");
420        assert_eq!(status.method(), "GET");
421        assert_eq!(
422            status.path().as_str(),
423            "/remote.php/dav/files/alice/missing.txt"
424        );
425    }
426
427    #[test]
428    fn put_uploads_body_and_sets_content_type() {
429        let transport = FakeTransport::new(201, b"");
430        let auth = FakeAuth;
431        let client = DavClient::new(&transport, &auth);
432        let path = DavPath::new("alice", "notes.md").expect("path is valid");
433
434        run(client.put(&path, b"# Hello".to_vec(), Some("text/markdown"))).expect("put succeeds");
435
436        let request = transport.last_request();
437        assert_eq!(request.method(), HttpMethod::Put);
438        assert_eq!(
439            request.path().as_str(),
440            "/remote.php/dav/files/alice/notes.md"
441        );
442        assert_eq!(request.headers().get("Content-Type"), Some("text/markdown"));
443        assert_eq!(
444            request.headers().get("Authorization"),
445            Some("Basic dGVzdDp0ZXN0")
446        );
447        assert_eq!(request.body(), Some(&b"# Hello"[..]));
448    }
449
450    #[test]
451    fn put_omits_content_type_when_none() {
452        let transport = FakeTransport::new(204, b"");
453        let auth = FakeAuth;
454        let client = DavClient::new(&transport, &auth);
455        let path = DavPath::new("alice", "raw.bin").expect("path is valid");
456
457        run(client.put(&path, vec![0xde, 0xad, 0xbe, 0xef], None)).expect("put succeeds");
458
459        let request = transport.last_request();
460        assert_eq!(request.headers().get("Content-Type"), None);
461        assert_eq!(request.body(), Some(&[0xde, 0xad, 0xbe, 0xef][..]));
462    }
463
464    #[test]
465    fn put_non_success_becomes_a_status_error() {
466        let transport = FakeTransport::new(409, "conflict");
467        let auth = FakeAuth;
468        let client = DavClient::new(&transport, &auth);
469        let path = DavPath::new("alice", "notes.md").expect("path is valid");
470
471        let error = run(client.put(&path, b"test".to_vec(), None)).expect_err("409 returns error");
472        assert_eq!(error.status_code(), Some(409));
473        let status = error.as_status().expect("status error");
474        assert_eq!(status.method(), "PUT");
475    }
476
477    #[test]
478    fn mkcol_creates_collection() {
479        let transport = FakeTransport::new(201, b"");
480        let auth = FakeAuth;
481        let client = DavClient::new(&transport, &auth);
482        let path = DavPath::new("alice", "NewFolder").expect("path is valid");
483
484        run(client.mkcol(&path)).expect("mkcol succeeds");
485
486        let request = transport.last_request();
487        assert_eq!(request.method(), HttpMethod::Mkcol);
488        assert_eq!(
489            request.path().as_str(),
490            "/remote.php/dav/files/alice/NewFolder"
491        );
492        assert_eq!(
493            request.headers().get("Authorization"),
494            Some("Basic dGVzdDp0ZXN0")
495        );
496        assert!(request.body().is_none());
497    }
498
499    #[test]
500    fn mkcol_non_success_becomes_a_status_error() {
501        let transport = FakeTransport::new(405, "not allowed");
502        let auth = FakeAuth;
503        let client = DavClient::new(&transport, &auth);
504        let path = DavPath::new("alice", "Folder").expect("path is valid");
505
506        let error = run(client.mkcol(&path)).expect_err("405 returns error");
507        assert_eq!(error.status_code(), Some(405));
508        let status = error.as_status().expect("status error");
509        assert_eq!(status.method(), "MKCOL");
510    }
511
512    #[test]
513    fn delete_removes_resource() {
514        let transport = FakeTransport::new(204, b"");
515        let auth = FakeAuth;
516        let client = DavClient::new(&transport, &auth);
517        let path = DavPath::new("alice", "old.txt").expect("path is valid");
518
519        run(client.delete(&path)).expect("delete succeeds");
520
521        let request = transport.last_request();
522        assert_eq!(request.method(), HttpMethod::Delete);
523        assert_eq!(
524            request.path().as_str(),
525            "/remote.php/dav/files/alice/old.txt"
526        );
527        assert_eq!(
528            request.headers().get("Authorization"),
529            Some("Basic dGVzdDp0ZXN0")
530        );
531    }
532
533    #[test]
534    fn delete_non_success_becomes_a_status_error() {
535        let transport = FakeTransport::new(404, "not found");
536        let auth = FakeAuth;
537        let client = DavClient::new(&transport, &auth);
538        let path = DavPath::new("alice", "old.txt").expect("path is valid");
539
540        let error = run(client.delete(&path)).expect_err("404 returns error");
541        assert_eq!(error.status_code(), Some(404));
542        let status = error.as_status().expect("status error");
543        assert_eq!(status.method(), "DELETE");
544    }
545
546    #[test]
547    fn copy_sends_destination_and_overwrite_headers() {
548        for (overwrite, expected_header) in [(true, "T"), (false, "F")] {
549            let transport = FakeTransport::new(201, b"");
550            let auth = FakeAuth;
551            let client = DavClient::new(&transport, &auth);
552            let source = DavPath::new("alice", "src.txt").expect("path is valid");
553            let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
554
555            run(client.copy(&source, &destination, overwrite)).expect("copy succeeds");
556
557            let request = transport.last_request();
558            assert_eq!(request.method(), HttpMethod::Copy);
559            assert_eq!(
560                request.path().as_str(),
561                "/remote.php/dav/files/alice/src.txt"
562            );
563            assert_eq!(
564                request.headers().get(DESTINATION_HEADER),
565                Some("/remote.php/dav/files/alice/dst.txt")
566            );
567            assert_eq!(
568                request.headers().get(OVERWRITE_HEADER),
569                Some(expected_header)
570            );
571            assert_eq!(
572                request.headers().get("Authorization"),
573                Some("Basic dGVzdDp0ZXN0")
574            );
575        }
576    }
577
578    #[test]
579    fn copy_non_success_becomes_a_status_error() {
580        let transport = FakeTransport::new(412, "precondition failed");
581        let auth = FakeAuth;
582        let client = DavClient::new(&transport, &auth);
583        let source = DavPath::new("alice", "src.txt").expect("path is valid");
584        let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
585
586        let error = run(client.copy(&source, &destination, false)).expect_err("412 returns error");
587        assert_eq!(error.status_code(), Some(412));
588        let status = error.as_status().expect("status error");
589        assert_eq!(status.method(), "COPY");
590        assert_eq!(
591            status.path().as_str(),
592            "/remote.php/dav/files/alice/src.txt"
593        );
594    }
595
596    #[test]
597    fn move_sends_destination_and_overwrite_headers() {
598        for (overwrite, expected_header) in [(true, "T"), (false, "F")] {
599            let transport = FakeTransport::new(201, b"");
600            let auth = FakeAuth;
601            let client = DavClient::new(&transport, &auth);
602            let source = DavPath::new("alice", "src.txt").expect("path is valid");
603            let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
604
605            run(client.move_(&source, &destination, overwrite)).expect("move succeeds");
606
607            let request = transport.last_request();
608            assert_eq!(request.method(), HttpMethod::Move);
609            assert_eq!(
610                request.path().as_str(),
611                "/remote.php/dav/files/alice/src.txt"
612            );
613            assert_eq!(
614                request.headers().get(DESTINATION_HEADER),
615                Some("/remote.php/dav/files/alice/dst.txt")
616            );
617            assert_eq!(
618                request.headers().get(OVERWRITE_HEADER),
619                Some(expected_header)
620            );
621            assert_eq!(
622                request.headers().get("Authorization"),
623                Some("Basic dGVzdDp0ZXN0")
624            );
625        }
626    }
627
628    #[test]
629    fn move_non_success_becomes_a_status_error() {
630        let transport = FakeTransport::new(412, "precondition failed");
631        let auth = FakeAuth;
632        let client = DavClient::new(&transport, &auth);
633        let source = DavPath::new("alice", "src.txt").expect("path is valid");
634        let destination = DavPath::new("alice", "dst.txt").expect("path is valid");
635
636        let error = run(client.move_(&source, &destination, false)).expect_err("412 returns error");
637        assert_eq!(error.status_code(), Some(412));
638        let status = error.as_status().expect("status error");
639        assert_eq!(status.method(), "MOVE");
640        assert_eq!(
641            status.path().as_str(),
642            "/remote.php/dav/files/alice/src.txt"
643        );
644    }
645}