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