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