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