1use 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
14const XML_CONTENT_TYPE: &str = "application/xml; charset=utf-8";
16
17#[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 pub const fn new(transport: &'a T, auth: &'a A) -> Self {
30 Self { transport, auth }
31 }
32
33 pub const fn transport(&self) -> &'a T {
35 self.transport
36 }
37
38 pub const fn auth(&self) -> &'a A {
40 self.auth
41 }
42
43 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 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 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 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 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 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 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 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 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 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}