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