api_dav.rs
⎇
Raw
1//! The WebDAV mounts: Basic auth, root scoping, the synthetic top level,
2//! reads and writes, and the share mount.
3
4mod common;
5
6use axum::http::{Method, StatusCode};
7use base64::Engine as _;
8use common::*;
9use serde_json::json;
10
11fn basic(name: &str, password: &str) -> String {
12 let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
13 format!("Basic {raw}")
14}
15
16fn method(name: &str) -> Method {
17 Method::from_bytes(name.as_bytes()).unwrap()
18}
19
20/// A dav request with an `Authorization` header instead of a session cookie.
21async fn dav(env: &Env, verb: &str, path: &str, auth: Option<&str>, body: &[u8]) -> Resp {
22 dav_with(env, verb, path, auth, &[], body).await
23}
24
25async fn dav_with(
26 env: &Env,
27 verb: &str,
28 path: &str,
29 auth: Option<&str>,
30 extra: &[(&str, &str)],
31 body: &[u8],
32) -> Resp {
33 let c = Client::new(env.app.clone());
34 let mut headers: Vec<(&str, &str)> = Vec::new();
35 // `Depth` is not a free choice: RFC 4918 fixes it at infinity for DELETE
36 // and MOVE, and a server that sees anything else answers 400.
37 if !extra.iter().any(|(k, _)| k.eq_ignore_ascii_case("depth")) {
38 match verb {
39 "PROPFIND" => headers.push(("depth", "1")),
40 "DELETE" | "MOVE" | "COPY" => headers.push(("depth", "infinity")),
41 _ => {}
42 }
43 }
44 if let Some(a) = auth {
45 headers.push(("authorization", a));
46 }
47 headers.extend_from_slice(extra);
48 c.raw(method(verb), path, &headers, body.to_vec()).await
49}
50
51/// The URL segment the admin's root (the whole server root) is mounted under.
52fn root_seg(env: &Env) -> String {
53 env.state.root_name.clone()
54}
55
56/// Create the admin account and return what nearly every test needs next: its
57/// `Authorization` header and the URL segment its root is mounted under.
58async fn admin_dav(env: &Env) -> (String, String) {
59 let _ = env.admin().await;
60 (basic("admin", "admin1234"), root_seg(env))
61}
62
63#[tokio::test]
64async fn unauthenticated_requests_get_a_basic_challenge() {
65 let env = Env::new().await;
66 let _ = env.admin().await;
67
68 for verb in ["OPTIONS", "PROPFIND", "GET"] {
69 let r = dav(&env, verb, "/dav", None, b"").await;
70 assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{verb} without auth");
71 // Without the challenge a mount client never offers credentials.
72 assert_eq!(
73 r.header("www-authenticate").as_deref(),
74 Some("Basic realm=\"filebrowser-ng\"")
75 );
76 }
77
78 // A wrong password is the same 401, not a 403.
79 let r = dav(&env, "PROPFIND", "/dav", Some(&basic("admin", "nope")), b"").await;
80 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
81}
82
83#[tokio::test]
84async fn propfind_lists_the_roots_then_their_contents() {
85 let env = Env::new().await;
86 let (auth, seg) = admin_dav(&env).await;
87
88 // The mount point is a synthetic collection holding one entry per root.
89 let r = dav(&env, "PROPFIND", "/dav", Some(&auth), b"").await;
90 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
91 let body = r.text();
92 assert!(body.contains("<D:multistatus"), "{body}");
93 assert!(body.contains(&format!("/dav/{seg}/")), "{body}");
94
95 let r = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await;
96 assert_eq!(r.status, StatusCode::MULTI_STATUS);
97 let body = r.text();
98 for name in ["docs", "src", "notes.md", "blob.bin"] {
99 assert!(body.contains(name), "{name} missing from {body}");
100 }
101 // Sizes come from the filesystem, not a guess.
102 assert!(body.contains("<D:getcontentlength>64<"), "{body}");
103}
104
105#[tokio::test]
106async fn get_and_put_round_trip_through_the_mount() {
107 let env = Env::new().await;
108 let (auth, seg) = admin_dav(&env).await;
109
110 let r = dav(
111 &env,
112 "GET",
113 &format!("/dav/{seg}/docs/inner/hello.txt"),
114 Some(&auth),
115 b"",
116 )
117 .await;
118 assert_eq!(r.status, StatusCode::OK);
119 assert_eq!(r.text(), "hello world");
120
121 // A PUT well past the router's 2 MiB `DefaultBodyLimit`. That limit only
122 // binds extractors that opt into it, and dav-server reads the body itself.
123 let big = vec![b'x'; 3 * 1024 * 1024];
124 let r = dav(
125 &env,
126 "PUT",
127 &format!("/dav/{seg}/big.bin"),
128 Some(&auth),
129 &big,
130 )
131 .await;
132 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
133 assert_eq!(std::fs::read(env.file("big.bin")).unwrap().len(), big.len());
134
135 let r = dav(
136 &env,
137 "PUT",
138 &format!("/dav/{seg}/editme.txt"),
139 Some(&auth),
140 b"v2",
141 )
142 .await;
143 assert!(r.status.is_success(), "{} {}", r.status, r.text());
144 assert_eq!(
145 std::fs::read_to_string(env.file("editme.txt")).unwrap(),
146 "v2"
147 );
148}
149
150#[tokio::test]
151async fn mkcol_move_copy_and_delete() {
152 let env = Env::new().await;
153 let (auth, seg) = admin_dav(&env).await;
154 let base = format!("/dav/{seg}");
155
156 let r = dav(&env, "MKCOL", &format!("{base}/fresh"), Some(&auth), b"").await;
157 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
158 assert!(env.file("fresh").is_dir());
159
160 // MKCOL over an existing name is a conflict, not a silent success.
161 let r = dav(&env, "MKCOL", &format!("{base}/fresh"), Some(&auth), b"").await;
162 assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
163
164 // MOVE renames as well as moves.
165 let r = dav_with(
166 &env,
167 "MOVE",
168 &format!("{base}/notes.md"),
169 Some(&auth),
170 &[("destination", &format!("{base}/fresh/renamed.md"))],
171 b"",
172 )
173 .await;
174 assert!(r.status.is_success(), "{} {}", r.status, r.text());
175 assert!(!env.file("notes.md").exists());
176 assert_eq!(
177 std::fs::read_to_string(env.file("fresh/renamed.md")).unwrap(),
178 "# notes"
179 );
180
181 // COPY of a whole tree: dav-server walks it, we create and copy per item.
182 let r = dav_with(
183 &env,
184 "COPY",
185 &format!("{base}/docs"),
186 Some(&auth),
187 &[
188 ("destination", &format!("{base}/docs-copy")),
189 ("depth", "infinity"),
190 ],
191 b"",
192 )
193 .await;
194 assert!(r.status.is_success(), "{} {}", r.status, r.text());
195 assert_eq!(
196 std::fs::read_to_string(env.file("docs-copy/inner/hello.txt")).unwrap(),
197 "hello world"
198 );
199 // The original survives a copy.
200 assert!(env.file("docs/inner/hello.txt").exists());
201
202 // DELETE of a collection takes the tree with it.
203 let r = dav(
204 &env,
205 "DELETE",
206 &format!("{base}/docs-copy"),
207 Some(&auth),
208 b"",
209 )
210 .await;
211 assert!(r.status.is_success(), "{} {}", r.status, r.text());
212 assert!(!env.file("docs-copy").exists());
213}
214
215#[tokio::test]
216async fn a_mount_cannot_leave_its_roots() {
217 let env = Env::new().await;
218 let admin = env.admin().await;
219 create_user(&admin, "dav-scoped", "scoped1234", &[("docs", "rw")]).await;
220 let auth = basic("dav-scoped", "scoped1234");
221
222 // Only the granted root is mounted.
223 let r = dav(&env, "PROPFIND", "/dav", Some(&auth), b"").await;
224 assert_eq!(r.status, StatusCode::MULTI_STATUS);
225 let body = r.text();
226 assert!(body.contains("/dav/docs/"), "{body}");
227 assert!(!body.contains("/dav/src/"), "{body}");
228
229 // A root that was never granted is not a path, it is a 404.
230 let r = dav(&env, "PROPFIND", "/dav/src/", Some(&auth), b"").await;
231 assert_eq!(r.status, StatusCode::NOT_FOUND);
232
233 // `..` does not climb out, whether the client spells it or not.
234 for path in ["/dav/docs/../src/main.rs", "/dav/docs/%2e%2e/src/main.rs"] {
235 let r = dav(&env, "GET", path, Some(&auth), b"").await;
236 assert!(r.status.is_client_error(), "{path} returned {}", r.status);
237 assert_ne!(r.text(), "fn main() {}");
238 }
239}
240
241#[tokio::test]
242async fn a_read_only_root_refuses_every_write() {
243 let env = Env::new().await;
244 let admin = env.admin().await;
245 create_user(&admin, "dav-reader", "reader1234", &[("docs", "ro")]).await;
246 let auth = basic("dav-reader", "reader1234");
247
248 let r = dav(&env, "GET", "/dav/docs/a.txt", Some(&auth), b"").await;
249 assert_eq!(r.status, StatusCode::OK);
250 assert_eq!(r.text(), "file a");
251
252 type Case = (
253 &'static str,
254 &'static str,
255 &'static [(&'static str, &'static str)],
256 );
257 const CASES: &[Case] = &[
258 ("PUT", "/dav/docs/new.txt", &[]),
259 ("MKCOL", "/dav/docs/new-dir", &[]),
260 ("DELETE", "/dav/docs/a.txt", &[]),
261 (
262 "MOVE",
263 "/dav/docs/a.txt",
264 &[("destination", "/dav/docs/b.txt")],
265 ),
266 ];
267 for (verb, path, extra) in CASES {
268 // A body only for PUT: RFC 4918 says MKCOL with one is a 415, which
269 // would answer before the read-only check ever runs.
270 let body: &[u8] = if *verb == "PUT" { b"body" } else { b"" };
271 let r = dav_with(&env, verb, path, Some(&auth), extra, body).await;
272 assert_eq!(r.status, StatusCode::FORBIDDEN, "{verb} {path}");
273 }
274 assert!(env.file("docs/a.txt").exists());
275 assert!(!env.file("docs/new.txt").exists());
276}
277
278#[tokio::test]
279async fn a_read_only_root_can_still_be_copied_out_of() {
280 let env = Env::new().await;
281 let admin = env.admin().await;
282 create_user(
283 &admin,
284 "dav-mixed",
285 "mixed12345",
286 &[("docs", "ro"), ("src", "rw")],
287 )
288 .await;
289 let auth = basic("dav-mixed", "mixed12345");
290
291 // Copying out of a read-only folder into a writable one only writes to the
292 // writable side, so it is allowed.
293 let r = dav_with(
294 &env,
295 "COPY",
296 "/dav/docs/a.txt",
297 Some(&auth),
298 &[("destination", "/dav/src/copied.txt")],
299 b"",
300 )
301 .await;
302 assert!(r.status.is_success(), "{} {}", r.status, r.text());
303 assert_eq!(
304 std::fs::read_to_string(env.file("src/copied.txt")).unwrap(),
305 "file a"
306 );
307
308 // Moving out of it is not: the source would lose the file.
309 let r = dav_with(
310 &env,
311 "MOVE",
312 "/dav/docs/a.txt",
313 Some(&auth),
314 &[("destination", "/dav/src/moved.txt")],
315 b"",
316 )
317 .await;
318 assert_eq!(r.status, StatusCode::FORBIDDEN);
319 assert!(env.file("docs/a.txt").exists());
320
321 // And the read-only folder still refuses to be the destination.
322 let r = dav_with(
323 &env,
324 "COPY",
325 "/dav/src/main.rs",
326 Some(&auth),
327 &[("destination", "/dav/docs/main.rs")],
328 b"",
329 )
330 .await;
331 assert_eq!(r.status, StatusCode::FORBIDDEN);
332 assert!(!env.file("docs/main.rs").exists());
333}
334
335#[tokio::test]
336async fn a_session_cookie_works_instead_of_basic() {
337 let env = Env::new().await;
338 let admin = env.admin().await;
339 let seg = root_seg(&env);
340
341 let r = admin
342 .raw(
343 method("PROPFIND"),
344 &format!("/dav/{seg}/"),
345 &[("depth", "1")],
346 Vec::new(),
347 )
348 .await;
349 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
350 assert!(r.text().contains("notes.md"));
351}
352
353#[tokio::test]
354async fn a_changed_password_locks_the_mount_out_at_once() {
355 let env = Env::new().await;
356 let admin = env.admin().await;
357 create_user(&admin, "dav-rotate", "rotate1234", &[("docs", "rw")]).await;
358 let id = user_id(&admin, "dav-rotate").await;
359 let old = basic("dav-rotate", "rotate1234");
360
361 let r = dav(&env, "PROPFIND", "/dav/docs/", Some(&old), b"").await;
362 assert_eq!(r.status, StatusCode::MULTI_STATUS);
363
364 let r = admin
365 .put_json(
366 &format!("/api/admin/users/{id}"),
367 &json!({ "password": "rotated5678" }),
368 )
369 .await;
370 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
371
372 // The old credential was cached a moment ago; it must not survive.
373 let r = dav(&env, "PROPFIND", "/dav/docs/", Some(&old), b"").await;
374 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
375 let r = dav(
376 &env,
377 "PROPFIND",
378 "/dav/docs/",
379 Some(&basic("dav-rotate", "rotated5678")),
380 b"",
381 )
382 .await;
383 assert_eq!(r.status, StatusCode::MULTI_STATUS);
384}
385
386// ---------------------------------------------------------------------------
387// Share mounts
388// ---------------------------------------------------------------------------
389
390async fn share(
391 admin: &Client,
392 path: &str,
393 writable: bool,
394 password: Option<&str>,
395) -> (String, i64) {
396 let r = admin
397 .post_json(
398 "/api/shares",
399 &json!({
400 "root_id": 1,
401 "path": path,
402 "writable": writable,
403 "password": password,
404 }),
405 )
406 .await;
407 assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
408 let j = r.json();
409 (
410 j["token"].as_str().unwrap().to_string(),
411 j["id"].as_i64().unwrap(),
412 )
413}
414
415#[tokio::test]
416async fn a_share_mounts_at_its_own_root_without_a_login() {
417 let env = Env::new().await;
418 let admin = env.admin().await;
419 let (token, _) = share(&admin, "docs", false, None).await;
420
421 let r = dav(&env, "PROPFIND", &format!("/dav-share/{token}/"), None, b"").await;
422 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
423 let body = r.text();
424 // The share target is the mount root, so its children sit directly under it.
425 assert!(
426 body.contains(&format!("/dav-share/{token}/a.txt")),
427 "{body}"
428 );
429 assert!(
430 body.contains(&format!("/dav-share/{token}/inner/")),
431 "{body}"
432 );
433 // Nothing above the share target is reachable.
434 assert!(!body.contains("notes.md"), "{body}");
435
436 let r = dav(
437 &env,
438 "GET",
439 &format!("/dav-share/{token}/inner/hello.txt"),
440 None,
441 b"",
442 )
443 .await;
444 assert_eq!(r.status, StatusCode::OK);
445 assert_eq!(r.text(), "hello world");
446
447 // A read-only share stays read-only over WebDAV too.
448 let r = dav(
449 &env,
450 "PUT",
451 &format!("/dav-share/{token}/new.txt"),
452 None,
453 b"x",
454 )
455 .await;
456 assert_eq!(r.status, StatusCode::FORBIDDEN);
457}
458
459#[tokio::test]
460async fn a_protected_share_asks_for_its_password_over_basic() {
461 let env = Env::new().await;
462 let admin = env.admin().await;
463 let (token, _) = share(&admin, "docs", false, Some("sharepass1")).await;
464 let url = format!("/dav-share/{token}/");
465
466 let r = dav(&env, "PROPFIND", &url, None, b"").await;
467 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
468 assert!(r.header("www-authenticate").is_some());
469
470 let r = dav(&env, "PROPFIND", &url, Some(&basic("", "wrong")), b"").await;
471 assert_eq!(r.status, StatusCode::UNAUTHORIZED);
472
473 // The user name is ignored: a share link has no account behind it.
474 let r = dav(
475 &env,
476 "PROPFIND",
477 &url,
478 Some(&basic("anyone", "sharepass1")),
479 b"",
480 )
481 .await;
482 assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text());
483}
484
485#[tokio::test]
486async fn a_writable_share_can_be_written_and_expiry_ends_it() {
487 let env = Env::new().await;
488 let admin = env.admin().await;
489 let r = admin
490 .put_json(
491 "/api/admin/settings",
492 &json!({ "allow_writable_shares": true }),
493 )
494 .await;
495 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
496 let (token, _) = share(&admin, "docs", true, None).await;
497
498 let r = dav(
499 &env,
500 "PUT",
501 &format!("/dav-share/{token}/dropped.txt"),
502 None,
503 b"from a mount",
504 )
505 .await;
506 assert_eq!(r.status, StatusCode::CREATED, "{}", r.text());
507 assert_eq!(
508 std::fs::read_to_string(env.file("docs/dropped.txt")).unwrap(),
509 "from a mount"
510 );
511
512 // An expired share is gone, not merely empty.
513 let r = admin
514 .post_json(
515 "/api/shares",
516 &json!({
517 "root_id": 1,
518 "path": "src",
519 "writable": false,
520 "expires_at": "2000-01-01T00:00:00Z",
521 }),
522 )
523 .await;
524 let dead = r.json()["token"].as_str().unwrap().to_string();
525 let r = dav(&env, "PROPFIND", &format!("/dav-share/{dead}/"), None, b"").await;
526 assert_eq!(r.status, StatusCode::GONE);
527
528 // A file share has no collection to mount.
529 let (file_token, _) = share(&admin, "notes.md", false, None).await;
530 let r = dav(
531 &env,
532 "PROPFIND",
533 &format!("/dav-share/{file_token}/"),
534 None,
535 b"",
536 )
537 .await;
538 assert_eq!(r.status, StatusCode::NOT_FOUND);
539
540 // An unknown token is a 404, never a hint.
541 let r = dav(&env, "PROPFIND", "/dav-share/deadbeef/", None, b"").await;
542 assert_eq!(r.status, StatusCode::NOT_FOUND);
543}
544
545#[tokio::test]
546async fn deleting_a_shared_path_over_webdav_revokes_the_share() {
547 let env = Env::new().await;
548 let admin = env.admin().await;
549 let auth = basic("admin", "admin1234");
550 let seg = root_seg(&env);
551 let (token, _) = share(&admin, "docs/inner", false, None).await;
552
553 // The share resolves while the folder is there.
554 let r = admin.get(&format!("/api/share/{token}")).await;
555 assert_eq!(r.status, StatusCode::OK);
556
557 let r = dav(
558 &env,
559 "DELETE",
560 &format!("/dav/{seg}/docs/inner"),
561 Some(&auth),
562 b"",
563 )
564 .await;
565 assert!(r.status.is_success(), "{} {}", r.status, r.text());
566
567 // A share pointing at a path that no longer exists must not linger.
568 let r = admin.get(&format!("/api/share/{token}")).await;
569 assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text());
570}
571
572// ---------------------------------------------------------------------------
573// Locking
574// ---------------------------------------------------------------------------
575
576const LOCK_BODY: &[u8] = br#"<?xml version="1.0" encoding="utf-8"?>
577<D:lockinfo xmlns:D="DAV:">
578 <D:lockscope><D:exclusive/></D:lockscope>
579 <D:locktype><D:write/></D:locktype>
580 <D:owner><D:href>client-one</D:href></D:owner>
581</D:lockinfo>"#;
582
583/// Take an exclusive lock and return its token.
584async fn lock(env: &Env, path: &str, auth: &str) -> (Resp, Option<String>) {
585 let r = dav_with(
586 env,
587 "LOCK",
588 path,
589 Some(auth),
590 &[("timeout", "Second-300")],
591 LOCK_BODY,
592 )
593 .await;
594 // The token arrives in `Lock-Token: <urn:uuid:…>`; the `If:` header wants
595 // it without the angle brackets.
596 let token = r
597 .header("lock-token")
598 .map(|v| v.trim_matches(['<', '>']).to_string());
599 (r, token)
600}
601
602#[tokio::test]
603async fn an_exclusive_lock_blocks_everyone_without_the_token() {
604 let env = Env::new().await;
605 let (auth, seg) = admin_dav(&env).await;
606 let path = format!("/dav/{seg}/editme.txt");
607
608 let (r, token) = lock(&env, &path, &auth).await;
609 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
610 let token = token.expect("LOCK must return a Lock-Token header");
611 assert!(token.starts_with("urn:uuid:"), "token was {token}");
612
613 let r = dav(&env, "PUT", &path, Some(&auth), b"from a second client").await;
614 assert_eq!(r.status, StatusCode::LOCKED);
615 assert_eq!(
616 std::fs::read_to_string(env.file("editme.txt")).unwrap(),
617 "v1"
618 );
619
620 let r = dav(&env, "DELETE", &path, Some(&auth), b"").await;
621 assert_eq!(r.status, StatusCode::LOCKED);
622
623 let (r, _) = lock(&env, &path, &auth).await;
624 assert_eq!(r.status, StatusCode::LOCKED);
625
626 // The holder writes by presenting the token.
627 let r = dav_with(
628 &env,
629 "PUT",
630 &path,
631 Some(&auth),
632 &[("if", &format!("(<{token}>)"))],
633 b"v2 from the holder",
634 )
635 .await;
636 assert!(r.status.is_success(), "{} {}", r.status, r.text());
637 assert_eq!(
638 std::fs::read_to_string(env.file("editme.txt")).unwrap(),
639 "v2 from the holder"
640 );
641
642 let r = dav_with(
643 &env,
644 "UNLOCK",
645 &path,
646 Some(&auth),
647 &[("lock-token", &format!("<{token}>"))],
648 b"",
649 )
650 .await;
651 assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text());
652 let r = dav(&env, "PUT", &path, Some(&auth), b"v3").await;
653 assert!(r.status.is_success(), "{} {}", r.status, r.text());
654}
655
656#[tokio::test]
657async fn a_lock_is_reported_and_its_timeout_is_capped() {
658 let env = Env::new().await;
659 let (auth, seg) = admin_dav(&env).await;
660 let path = format!("/dav/{seg}/notes.md");
661
662 // No `Timeout` header at all reaches the lock system as "no expiry", which
663 // is the lock nothing can ever sweep. It comes back capped instead.
664 let r = dav_with(&env, "LOCK", &path, Some(&auth), &[], LOCK_BODY).await;
665 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
666 let body = r.text();
667 assert!(body.contains("<D:timeout>Second-600</D:timeout>"), "{body}");
668 assert!(!body.contains("Infinite"), "{body}");
669
670 // PROPFIND must report the lock, or a client cannot see its own.
671 let r = dav_with(&env, "PROPFIND", &path, Some(&auth), &[("depth", "0")], b"").await;
672 assert_eq!(r.status, StatusCode::MULTI_STATUS);
673 let body = r.text();
674 assert!(body.contains("<D:activelock>"), "{body}");
675 assert!(body.contains("client-one"), "{body}");
676}
677
678#[tokio::test]
679async fn locks_are_scoped_to_their_own_path() {
680 let env = Env::new().await;
681 let (auth, seg) = admin_dav(&env).await;
682
683 let (r, _) = lock(&env, &format!("/dav/{seg}/notes.md"), &auth).await;
684 assert_eq!(r.status, StatusCode::OK);
685
686 // A lock on one file must not block its neighbours.
687 let r = dav(
688 &env,
689 "PUT",
690 &format!("/dav/{seg}/config.json"),
691 Some(&auth),
692 b"{}",
693 )
694 .await;
695 assert!(r.status.is_success(), "{} {}", r.status, r.text());
696}
697
698#[tokio::test]
699async fn an_abandoned_lock_expires() {
700 let env = Env::new().await;
701 let (auth, seg) = admin_dav(&env).await;
702 let path = format!("/dav/{seg}/editme.txt");
703
704 // A one-second lock, then no refresh: the client is gone.
705 let r = dav_with(
706 &env,
707 "LOCK",
708 &path,
709 Some(&auth),
710 &[("timeout", "Second-1")],
711 LOCK_BODY,
712 )
713 .await;
714 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
715
716 let r = dav(&env, "PUT", &path, Some(&auth), b"too early").await;
717 assert_eq!(r.status, StatusCode::LOCKED);
718
719 tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
720
721 // Swept on the next request that touches the path. Without the sweep this
722 // file would stay locked until the process restarts.
723 let r = dav(&env, "PUT", &path, Some(&auth), b"after expiry").await;
724 assert!(r.status.is_success(), "{} {}", r.status, r.text());
725 assert_eq!(
726 std::fs::read_to_string(env.file("editme.txt")).unwrap(),
727 "after expiry"
728 );
729}
730
731#[tokio::test]
732async fn concurrent_writers_leave_a_whole_file() {
733 let env = Env::new().await;
734 let (auth, seg) = admin_dav(&env).await;
735 let path = format!("/dav/{seg}/contended.bin");
736
737 // Different lengths, so a splice of the two is obvious: it would be
738 // 400_000 bytes long with the shorter body's bytes somewhere inside.
739 let long = vec![b'A'; 400_000];
740 let short = vec![b'B'; 200_000];
741 let (a, b) = tokio::join!(
742 dav(&env, "PUT", &path, Some(&auth), &long),
743 dav(&env, "PUT", &path, Some(&auth), &short),
744 );
745 assert!(a.status.is_success(), "{}", a.status);
746 assert!(b.status.is_success(), "{}", b.status);
747
748 // Whichever writer landed last, the file is one of the two bodies and not
749 // a mixture.
750 let got = std::fs::read(env.file("contended.bin")).unwrap();
751 assert!(
752 got == long || got == short,
753 "file is neither body: {} bytes, {} A, {} B",
754 got.len(),
755 got.iter().filter(|&&c| c == b'A').count(),
756 got.iter().filter(|&&c| c == b'B').count(),
757 );
758}
759
760#[tokio::test]
761async fn copy_replaces_a_symlink_instead_of_writing_through_it() {
762 let env = Env::new().await;
763 let (auth, seg) = admin_dav(&env).await;
764
765 // A symlink inside the root aimed at a file outside it. The app cannot
766 // create one, but anything else with access to the folder can.
767 let outside = env.root.path().parent().unwrap().join("outside.txt");
768 std::fs::write(&outside, "SECRET").unwrap();
769 std::os::unix::fs::symlink(&outside, env.file("link.txt")).unwrap();
770
771 // `std::fs::copy` follows a destination symlink, so without unlinking it
772 // first the copy lands outside the root with every path check passing.
773 let r = dav_with(
774 &env,
775 "COPY",
776 &format!("/dav/{seg}/notes.md"),
777 Some(&auth),
778 &[("destination", &format!("/dav/{seg}/link.txt"))],
779 b"",
780 )
781 .await;
782 assert!(r.status.is_success(), "{} {}", r.status, r.text());
783 assert_eq!(
784 std::fs::read_to_string(&outside).unwrap(),
785 "SECRET",
786 "the copy escaped the root"
787 );
788 assert_eq!(
789 std::fs::read_to_string(env.file("link.txt")).unwrap(),
790 "# notes"
791 );
792 assert!(
793 !env.file("link.txt")
794 .symlink_metadata()
795 .unwrap()
796 .file_type()
797 .is_symlink()
798 );
799
800 // A link pointing *inside* the root is treated the same way. Following it
801 // would overwrite a file the request never named.
802 std::os::unix::fs::symlink(env.file("config.json"), env.file("inside.txt")).unwrap();
803 let r = dav_with(
804 &env,
805 "COPY",
806 &format!("/dav/{seg}/notes.md"),
807 Some(&auth),
808 &[("destination", &format!("/dav/{seg}/inside.txt"))],
809 b"",
810 )
811 .await;
812 assert!(r.status.is_success(), "{} {}", r.status, r.text());
813 assert_eq!(
814 std::fs::read_to_string(env.file("inside.txt")).unwrap(),
815 "# notes"
816 );
817 assert_eq!(
818 std::fs::read_to_string(env.file("config.json")).unwrap(),
819 "{\"k\": 1}",
820 "the copy went through the link"
821 );
822}
823
824#[tokio::test]
825async fn deleting_a_symlink_removes_the_link_not_its_target() {
826 let env = Env::new().await;
827 let (auth, seg) = admin_dav(&env).await;
828
829 std::os::unix::fs::symlink(env.file("notes.md"), env.file("alias.md")).unwrap();
830 let r = dav(
831 &env,
832 "DELETE",
833 &format!("/dav/{seg}/alias.md"),
834 Some(&auth),
835 b"",
836 )
837 .await;
838 assert!(r.status.is_success(), "{} {}", r.status, r.text());
839
840 assert!(env.file("alias.md").symlink_metadata().is_err());
841 assert_eq!(
842 std::fs::read_to_string(env.file("notes.md")).unwrap(),
843 "# notes",
844 "the delete followed the link"
845 );
846}
847
848#[tokio::test]
849async fn a_dangling_symlink_is_not_a_writable_destination() {
850 let env = Env::new().await;
851 let (auth, seg) = admin_dav(&env).await;
852
853 let outside = env.root.path().parent().unwrap().join("never-created.txt");
854 std::os::unix::fs::symlink(&outside, env.file("dangling.txt")).unwrap();
855
856 // It resolves to nothing, so the strict pass reports "not found". Creating
857 // through it would put the file outside the root.
858 let r = dav(
859 &env,
860 "PUT",
861 &format!("/dav/{seg}/dangling.txt"),
862 Some(&auth),
863 b"payload",
864 )
865 .await;
866 assert_eq!(r.status, StatusCode::FORBIDDEN);
867 assert!(!outside.exists(), "the write escaped the root");
868}
869
870#[tokio::test]
871async fn a_copy_and_a_put_to_one_path_do_not_interleave() {
872 let env = Env::new().await;
873 let (auth, seg) = admin_dav(&env).await;
874
875 let source = vec![b'S'; 300_000];
876 std::fs::write(env.file("source.bin"), &source).unwrap();
877 let put = vec![b'P'; 150_000];
878
879 // COPY writes its destination through `fs::copy_file_to`, not through the
880 // same `open()` a PUT uses, so it has to take the write mutex itself.
881 let path = format!("/dav/{seg}/contended.bin");
882 let src_path = format!("/dav/{seg}/source.bin");
883 let dest = [("destination", path.as_str())];
884 let (c, p) = tokio::join!(
885 dav_with(&env, "COPY", &src_path, Some(&auth), &dest, b""),
886 dav(&env, "PUT", &path, Some(&auth), &put),
887 );
888 assert!(c.status.is_success(), "copy: {}", c.status);
889 assert!(p.status.is_success(), "put: {}", p.status);
890
891 let got = std::fs::read(env.file("contended.bin")).unwrap();
892 assert!(
893 got == source || got == put,
894 "file is neither body: {} bytes, {} S, {} P",
895 got.len(),
896 got.iter().filter(|&&c| c == b'S').count(),
897 got.iter().filter(|&&c| c == b'P').count(),
898 );
899}
900
901#[tokio::test]
902async fn deleting_a_symlinked_directory_does_not_empty_its_target() {
903 let env = Env::new().await;
904 let (auth, seg) = admin_dav(&env).await;
905
906 // A link to a directory, both directly under the mount and nested inside
907 // a folder that gets deleted as a whole.
908 std::fs::create_dir_all(env.file("tree")).unwrap();
909 std::fs::write(env.file("tree/keep.txt"), "kept").unwrap();
910 std::os::unix::fs::symlink(env.file("docs"), env.file("linked")).unwrap();
911 std::os::unix::fs::symlink(env.file("docs"), env.file("tree/linked")).unwrap();
912
913 // Directly: `dav-server` asks `symlink_metadata` first, so it sees a link
914 // rather than a collection and never starts a walk.
915 let r = dav(
916 &env,
917 "DELETE",
918 &format!("/dav/{seg}/linked"),
919 Some(&auth),
920 b"",
921 )
922 .await;
923 assert!(r.status.is_success(), "{} {}", r.status, r.text());
924 assert!(env.file("linked").symlink_metadata().is_err());
925 assert!(
926 env.file("docs/a.txt").exists(),
927 "the delete followed the link"
928 );
929
930 // Recursively: the walk asks `read_dir` for unfollowed metadata, so the
931 // nested link is a file to unlink, not a directory to descend into.
932 let r = dav(
933 &env,
934 "DELETE",
935 &format!("/dav/{seg}/tree"),
936 Some(&auth),
937 b"",
938 )
939 .await;
940 assert!(r.status.is_success(), "{} {}", r.status, r.text());
941 assert!(!env.file("tree").exists());
942 assert!(
943 env.file("docs/a.txt").exists(),
944 "the recursive delete followed the link"
945 );
946 assert!(env.file("docs/inner/hello.txt").exists());
947}
948
949#[tokio::test]
950async fn moving_a_symlinked_directory_moves_the_link() {
951 let env = Env::new().await;
952 let (auth, seg) = admin_dav(&env).await;
953
954 std::os::unix::fs::symlink(env.file("docs"), env.file("linked")).unwrap();
955
956 // This holds because `fs::move_to` resolves its source as an entry, so
957 // the rename moves the link whatever `dav-server` believed. The honest
958 // `symlink_metadata` only decides the trailing slash on the path here.
959 let r = dav_with(
960 &env,
961 "MOVE",
962 &format!("/dav/{seg}/linked"),
963 Some(&auth),
964 &[("destination", &format!("/dav/{seg}/src/linked"))],
965 b"",
966 )
967 .await;
968 assert!(r.status.is_success(), "{} {}", r.status, r.text());
969
970 assert!(
971 env.file("src/linked")
972 .symlink_metadata()
973 .unwrap()
974 .file_type()
975 .is_symlink()
976 );
977 assert!(!env.file("linked").exists());
978 // `docs` stayed where it was, with its contents.
979 assert!(env.file("docs/a.txt").exists());
980}
981
982#[tokio::test]
983async fn a_listing_still_shows_a_symlink_as_its_target() {
984 let env = Env::new().await;
985 let (auth, seg) = admin_dav(&env).await;
986
987 // 64 bytes of fixture data behind the link.
988 std::os::unix::fs::symlink(env.file("blob.bin"), env.file("alias.bin")).unwrap();
989 std::os::unix::fs::symlink(env.file("docs"), env.file("linked")).unwrap();
990
991 let r = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await;
992 assert_eq!(r.status, StatusCode::MULTI_STATUS);
993 let body = r.text();
994
995 // Followed, so the link reports the target's size, not the link's own.
996 assert!(body.contains("<D:getcontentlength>64<"), "{body}");
997 // And a link to a directory is still a collection, with a trailing slash.
998 assert!(body.contains(&format!("/dav/{seg}/linked/")), "{body}");
999 assert!(body.contains(&format!("/dav/{seg}/alias.bin")), "{body}");
1000}
1001
1002// ---------------------------------------------------------------------------
1003// The mount point and a root itself are not deletable
1004// ---------------------------------------------------------------------------
1005
1006#[tokio::test]
1007async fn deleting_the_mount_point_removes_nothing() {
1008 let env = Env::new().await;
1009 let _ = env.admin().await;
1010 let auth = basic("admin", "admin1234");
1011
1012 // `dav-server` deletes a collection's children first and the collection
1013 // last, so a refusal that only fires on the final step comes after every
1014 // file is already gone.
1015 let r = dav(&env, "DELETE", "/dav/", Some(&auth), b"").await;
1016 assert!(!r.status.is_success(), "{} {}", r.status, r.text());
1017
1018 for f in [
1019 "notes.md",
1020 "docs/a.txt",
1021 "docs/inner/hello.txt",
1022 "src/main.rs",
1023 ] {
1024 assert!(env.file(f).exists(), "{f} was deleted");
1025 }
1026}
1027
1028#[tokio::test]
1029async fn deleting_a_root_removes_nothing() {
1030 let env = Env::new().await;
1031 let admin = env.admin().await;
1032 create_user(&admin, "dav-root-del", "rootdel1234", &[("docs", "rw")]).await;
1033 let auth = basic("dav-root-del", "rootdel1234");
1034
1035 let r = dav(&env, "DELETE", "/dav/docs", Some(&auth), b"").await;
1036 assert!(!r.status.is_success(), "{} {}", r.status, r.text());
1037 assert!(env.file("docs/a.txt").exists());
1038 assert!(env.file("docs/inner/hello.txt").exists());
1039}
1040
1041#[tokio::test]
1042async fn a_move_cannot_wipe_a_root_through_its_destination() {
1043 let env = Env::new().await;
1044 let admin = env.admin().await;
1045 create_user(
1046 &admin,
1047 "dav-two-roots",
1048 "tworoots1234",
1049 &[("docs", "rw"), ("src", "rw")],
1050 )
1051 .await;
1052 let auth = basic("dav-two-roots", "tworoots1234");
1053
1054 // `Overwrite: T` makes dav-server delete the destination first, and the
1055 // destination here is a whole root.
1056 let r = dav_with(
1057 &env,
1058 "MOVE",
1059 "/dav/docs",
1060 Some(&auth),
1061 &[("destination", "/dav/src"), ("overwrite", "T")],
1062 b"",
1063 )
1064 .await;
1065 assert!(!r.status.is_success(), "{} {}", r.status, r.text());
1066 assert!(env.file("src/main.rs").exists(), "the root was wiped");
1067 assert!(env.file("docs/a.txt").exists());
1068}
1069
1070#[tokio::test]
1071async fn a_scriptable_file_is_sandboxed_over_dav() {
1072 let env = Env::new().await;
1073 let admin = env.admin().await;
1074 let auth = basic("admin", "admin1234");
1075 let seg = root_seg(&env);
1076 std::fs::write(env.file("evil.html"), "<script>alert(1)</script>").unwrap();
1077
1078 // A top-level navigation to this URL carries the session cookie, so the
1079 // app's own policy would let the page act as the signed-in user.
1080 let r = dav(
1081 &env,
1082 "GET",
1083 &format!("/dav/{seg}/evil.html"),
1084 Some(&auth),
1085 b"",
1086 )
1087 .await;
1088 assert_eq!(r.status, StatusCode::OK);
1089 let csp = r.header("content-security-policy").unwrap_or_default();
1090 assert!(csp.contains("sandbox allow-scripts"), "policy was: {csp}");
1091 assert!(!csp.contains("allow-same-origin"), "policy was: {csp}");
1092
1093 // Same through a public share, which needs no account at all.
1094 let (token, _) = share(&admin, ".", false, None).await;
1095 let r = dav(
1096 &env,
1097 "GET",
1098 &format!("/dav-share/{token}/evil.html"),
1099 None,
1100 b"",
1101 )
1102 .await;
1103 assert_eq!(r.status, StatusCode::OK);
1104 let csp = r.header("content-security-policy").unwrap_or_default();
1105 assert!(csp.contains("sandbox allow-scripts"), "policy was: {csp}");
1106
1107 // A non-scriptable file keeps the app policy; only documents are sandboxed.
1108 let r = dav(
1109 &env,
1110 "GET",
1111 &format!("/dav/{seg}/blob.bin"),
1112 Some(&auth),
1113 b"",
1114 )
1115 .await;
1116 assert_eq!(r.status, StatusCode::OK);
1117 assert!(
1118 !r.header("content-security-policy")
1119 .unwrap_or_default()
1120 .contains("sandbox")
1121 );
1122}
1123
1124#[tokio::test]
1125async fn two_users_with_same_named_roots_do_not_share_locks() {
1126 let env = Env::new().await;
1127 let admin = env.admin().await;
1128
1129 // Different folders, same basename, so both mount at `/dav/Documents`.
1130 for owner in ["alpha", "beta"] {
1131 std::fs::create_dir_all(env.file(&format!("{owner}/Documents"))).unwrap();
1132 std::fs::write(env.file(&format!("{owner}/Documents/x.txt")), owner).unwrap();
1133 }
1134 create_user(
1135 &admin,
1136 "dav-alpha",
1137 "alpha12345",
1138 &[("alpha/Documents", "rw")],
1139 )
1140 .await;
1141 create_user(
1142 &admin,
1143 "dav-beta",
1144 "beta123456",
1145 &[("beta/Documents", "rw")],
1146 )
1147 .await;
1148 let a = basic("dav-alpha", "alpha12345");
1149 let b = basic("dav-beta", "beta123456");
1150
1151 let (r, token) = lock(&env, "/dav/Documents/x.txt", &a).await;
1152 assert_eq!(r.status, StatusCode::OK, "{}", r.text());
1153 let token = token.unwrap();
1154
1155 // Same URL, different user, different file. A shared lock tree would
1156 // refuse this with 423.
1157 let r = dav(&env, "PUT", "/dav/Documents/x.txt", Some(&b), b"beta wrote").await;
1158 assert!(r.status.is_success(), "{} {}", r.status, r.text());
1159 assert_eq!(
1160 std::fs::read_to_string(env.file("beta/Documents/x.txt")).unwrap(),
1161 "beta wrote"
1162 );
1163 assert_eq!(
1164 std::fs::read_to_string(env.file("alpha/Documents/x.txt")).unwrap(),
1165 "alpha"
1166 );
1167
1168 // And the holder's token is not visible to the other user.
1169 let r = dav_with(
1170 &env,
1171 "PROPFIND",
1172 "/dav/Documents/x.txt",
1173 Some(&b),
1174 &[("depth", "0")],
1175 b"",
1176 )
1177 .await;
1178 assert!(!r.text().contains(&token), "the lock token leaked");
1179
1180 // The holder still owns its own lock.
1181 let r = dav(&env, "PUT", "/dav/Documents/x.txt", Some(&a), b"nope").await;
1182 assert_eq!(r.status, StatusCode::LOCKED);
1183}
1184
1185#[tokio::test]
1186async fn deleting_a_symlink_does_not_revoke_its_targets_share() {
1187 let env = Env::new().await;
1188 let admin = env.admin().await;
1189 let auth = basic("admin", "admin1234");
1190 let seg = root_seg(&env);
1191 std::os::unix::fs::symlink(env.file("notes.md"), env.file("alias.md")).unwrap();
1192
1193 let (token, _) = share(&admin, "notes.md", false, None).await;
1194
1195 // The share names `notes.md`. Deleting the link leaves that file in place,
1196 // so the share must survive.
1197 let r = dav(
1198 &env,
1199 "DELETE",
1200 &format!("/dav/{seg}/alias.md"),
1201 Some(&auth),
1202 b"",
1203 )
1204 .await;
1205 assert!(r.status.is_success(), "{} {}", r.status, r.text());
1206 assert!(env.file("notes.md").exists());
1207
1208 let r = admin.get(&format!("/api/share/{token}")).await;
1209 assert_eq!(
1210 r.status,
1211 StatusCode::OK,
1212 "the share was revoked: {}",
1213 r.text()
1214 );
1215}
1216
1217#[tokio::test]
1218async fn a_dangling_symlink_is_still_listed() {
1219 let env = Env::new().await;
1220 let (auth, seg) = admin_dav(&env).await;
1221 std::os::unix::fs::symlink(env.file("never-existed"), env.file("dangling.md")).unwrap();
1222
1223 // It has no target to stat. Dropping it from the listing would read to a
1224 // sync client as a deletion to mirror, and the JSON API lists it too.
1225 let r = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await;
1226 assert_eq!(r.status, StatusCode::MULTI_STATUS);
1227 assert!(r.text().contains("dangling.md"), "{}", r.text());
1228}
1229
1230#[tokio::test]
1231async fn a_browser_get_of_a_collection_returns_a_listing() {
1232 let env = Env::new().await;
1233 let (auth, seg) = admin_dav(&env).await;
1234
1235 // Both the synthetic top level and a real directory answer a plain GET.
1236 // Without `autoindex` each would be 405.
1237 let top = dav(&env, "GET", "/dav/", Some(&auth), b"").await;
1238 assert_eq!(top.status, StatusCode::OK);
1239 assert!(top.text().contains("Index of"));
1240
1241 let dir = dav(&env, "GET", &format!("/dav/{seg}/docs/"), Some(&auth), b"").await;
1242 assert_eq!(dir.status, StatusCode::OK);
1243 assert!(dir.text().contains("inner"));
1244
1245 // A listing is server-generated HTML, so it still gets the file policy.
1246 assert!(
1247 dir.header("content-security-policy")
1248 .is_some_and(|v| v.contains("sandbox"))
1249 );
1250}
1251
1252/// `dav-server` skips dot-prefixed names when it generates a listing. PROPFIND
1253/// does not, so this only costs visibility in a browser, never a mount.
1254#[tokio::test]
1255async fn a_listing_omits_dotfiles() {
1256 let env = Env::new().await;
1257 let (auth, seg) = admin_dav(&env).await;
1258 std::fs::write(env.file(".hidden"), "x").unwrap();
1259
1260 let listing = dav(&env, "GET", &format!("/dav/{seg}/"), Some(&auth), b"").await;
1261 assert!(!listing.text().contains(".hidden"));
1262
1263 let props = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await;
1264 assert_eq!(props.status, StatusCode::MULTI_STATUS);
1265 assert!(props.text().contains(".hidden"));
1266}
1267
1268#[tokio::test]
1269async fn a_listing_escapes_entry_names() {
1270 let env = Env::new().await;
1271 let (auth, seg) = admin_dav(&env).await;
1272 std::fs::write(env.file("<img src=x onerror=alert(1)>.txt"), "x").unwrap();
1273
1274 let r = dav(&env, "GET", &format!("/dav/{seg}/"), Some(&auth), b"").await;
1275 assert_eq!(r.status, StatusCode::OK);
1276 let body = r.text();
1277 assert!(body.contains("&lt;img src=x onerror=alert(1)&gt;.txt"));
1278 assert!(!body.contains("<img src=x"));
1279}
1280
1281/// The token is read off the *raw* URL path, because `DavPath` keeps the raw
1282/// path too and `strip_prefix` byte-compares against it. Taking axum's decoded
1283/// wildcard instead would split a valid token out of `<token>%2Fx` and then
1284/// hand `dav-server` a prefix its own path does not start with.
1285#[tokio::test]
1286async fn an_encoded_slash_does_not_split_the_share_token() {
1287 let env = Env::new().await;
1288 let admin = env.admin().await;
1289 let (token, _) = share(&admin, "docs", false, None).await;
1290
1291 let r = dav(
1292 &env,
1293 "PROPFIND",
1294 &format!("/dav-share/{token}%2Fa.txt"),
1295 None,
1296 b"",
1297 )
1298 .await;
1299 assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text());
1300}
1301