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