//! The WebDAV mounts: Basic auth, root scoping, the synthetic top level, //! reads and writes, and the share mount. mod common; use axum::http::{Method, StatusCode}; use common::*; use serde_json::json; fn method(name: &str) -> Method { Method::from_bytes(name.as_bytes()).unwrap() } /// A dav request with an `Authorization` header instead of a session cookie. async fn dav(env: &Env, verb: &str, path: &str, auth: Option<&str>, body: &[u8]) -> Resp { dav_with(env, verb, path, auth, &[], body).await } async fn dav_with( env: &Env, verb: &str, path: &str, auth: Option<&str>, extra: &[(&str, &str)], body: &[u8], ) -> Resp { let c = Client::new(env.app.clone()); let mut headers: Vec<(&str, &str)> = Vec::new(); // `Depth` is not a free choice: RFC 4918 fixes it at infinity for DELETE // and MOVE, and a server that sees anything else answers 400. if !extra.iter().any(|(k, _)| k.eq_ignore_ascii_case("depth")) { match verb { "PROPFIND" => headers.push(("depth", "1")), "DELETE" | "MOVE" | "COPY" => headers.push(("depth", "infinity")), _ => {} } } if let Some(a) = auth { headers.push(("authorization", a)); } headers.extend_from_slice(extra); c.raw(method(verb), path, &headers, body.to_vec()).await } /// The URL segment the admin's root (the whole server root) is mounted under. fn root_seg(env: &Env) -> String { env.state.root_name.clone() } /// Create the admin account and return what nearly every test needs next: its /// `Authorization` header and the URL segment its root is mounted under. async fn admin_dav(env: &Env) -> (String, String) { let _ = env.admin().await; (basic("admin", "admin1234"), root_seg(env)) } #[tokio::test] async fn unauthenticated_requests_get_a_basic_challenge() { let env = Env::new().await; let _ = env.admin().await; for verb in ["OPTIONS", "PROPFIND", "GET"] { let r = dav(&env, verb, "/dav", None, b"").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{verb} without auth"); // Without the challenge a mount client never offers credentials. assert_eq!( r.header("www-authenticate").as_deref(), Some("Basic realm=\"dovenest\", charset=\"UTF-8\"") ); } // A wrong password is the same 401, not a 403. let r = dav(&env, "PROPFIND", "/dav", Some(&basic("admin", "nope")), b"").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn propfind_lists_the_roots_then_their_contents() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; // The mount point is a synthetic collection holding one entry per root. let r = dav(&env, "PROPFIND", "/dav", Some(&auth), b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); let body = r.text(); assert!(body.contains("64<"), "{body}"); } #[tokio::test] async fn get_and_put_round_trip_through_the_mount() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let r = dav( &env, "GET", &format!("/dav/{seg}/docs/inner/hello.txt"), Some(&auth), b"", ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), "hello world"); // A PUT well past the router's 2 MiB `DefaultBodyLimit`. That limit only // binds extractors that opt into it, and dav-server reads the body itself. let big = vec![b'x'; 3 * 1024 * 1024]; let r = dav( &env, "PUT", &format!("/dav/{seg}/big.bin"), Some(&auth), &big, ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); assert_eq!(std::fs::read(env.file("big.bin")).unwrap().len(), big.len()); let r = dav( &env, "PUT", &format!("/dav/{seg}/editme.txt"), Some(&auth), b"v2", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(env.file("editme.txt")).unwrap(), "v2" ); } #[tokio::test] async fn mkcol_move_copy_and_delete() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let base = format!("/dav/{seg}"); let r = dav(&env, "MKCOL", &format!("{base}/fresh"), Some(&auth), b"").await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); assert!(env.file("fresh").is_dir()); // MKCOL over an existing name is a conflict, not a silent success. let r = dav(&env, "MKCOL", &format!("{base}/fresh"), Some(&auth), b"").await; assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED); // MOVE renames as well as moves. let r = dav_with( &env, "MOVE", &format!("{base}/notes.md"), Some(&auth), &[("destination", &format!("{base}/fresh/renamed.md"))], b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert!(!env.file("notes.md").exists()); assert_eq!( std::fs::read_to_string(env.file("fresh/renamed.md")).unwrap(), "# notes" ); // COPY of a whole tree: dav-server walks it, we create and copy per item. let r = dav_with( &env, "COPY", &format!("{base}/docs"), Some(&auth), &[ ("destination", &format!("{base}/docs-copy")), ("depth", "infinity"), ], b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(env.file("docs-copy/inner/hello.txt")).unwrap(), "hello world" ); // The original survives a copy. assert!(env.file("docs/inner/hello.txt").exists()); // DELETE of a collection takes the tree with it. let r = dav( &env, "DELETE", &format!("{base}/docs-copy"), Some(&auth), b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert!(!env.file("docs-copy").exists()); } #[tokio::test] async fn a_mount_cannot_leave_its_roots() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "dav-scoped", "scoped1234", &[("docs", "rw")]).await; let auth = basic("dav-scoped", "scoped1234"); // Only the granted root is mounted. let r = dav(&env, "PROPFIND", "/dav", Some(&auth), b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let body = r.text(); assert!(body.contains("/dav/docs/"), "{body}"); assert!(!body.contains("/dav/src/"), "{body}"); // A root that was never granted is not a path, it is a 404. let r = dav(&env, "PROPFIND", "/dav/src/", Some(&auth), b"").await; assert_eq!(r.status, StatusCode::NOT_FOUND); // `..` does not climb out, whether the client spells it or not. for path in ["/dav/docs/../src/main.rs", "/dav/docs/%2e%2e/src/main.rs"] { let r = dav(&env, "GET", path, Some(&auth), b"").await; assert!(r.status.is_client_error(), "{path} returned {}", r.status); assert_ne!(r.text(), "fn main() {}"); } } #[tokio::test] async fn a_path_that_climbs_out_of_the_mount_is_not_a_server_error() { let env = Env::new().await; // Not `admin_dav`: the share below needs the client too, so both halves // are set up here. let admin = env.admin().await; let auth = basic("admin", "admin1234"); let seg = root_seg(&env); // `a_mount_cannot_leave_its_roots` covers a `..` that stays inside the // mount. This is the other case: enough `..` to climb out entirely. // `dav-server` answers that with `DavError::IllegalPath`, a `502` that // reads as a broken upstream. The sideways case is a 4xx, so this must be. for path in [ "/dav/%2e%2e/etc/passwd", "/dav/../etc/passwd", &format!("/dav/{seg}/docs/../../../outside.txt"), ] { let r = dav(&env, "PROPFIND", path, Some(&auth), b"").await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}: {}", r.status); } // One `..` short of the escape: still inside the mount, so it gets the // ordinary answer for a folder that is not mounted. let r = dav( &env, "PROPFIND", &format!("/dav/{seg}/docs/../../x"), Some(&auth), b"", ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); // What the normalization itself rejects keeps the status it had: an encoded // slash is a malformed segment, not an escape attempt. let r = dav( &env, "GET", "/dav/docs/..%2F..%2Foutside.txt", Some(&auth), b"", ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // The `Destination` of a COPY or MOVE is a path too, parsed the same way. // As a bare path, and as the full URL a mount client sends. for dest in [ "/etc/outside.txt", "http://localhost/dav/../etc/outside.txt", ] { for verb in ["MOVE", "COPY"] { let r = dav_with( &env, verb, &format!("/dav/{seg}/docs/inner/hello.txt"), Some(&auth), &[("destination", dest)], b"", ) .await; assert_eq!( r.status, StatusCode::FORBIDDEN, "{verb} to {dest}: {}", r.status ); } } assert!( env.file("docs/inner/hello.txt").exists(), "the source is untouched" ); // A share mount is a mount point too, and it is the one strangers reach. let (token, _) = share(&admin, "docs", false, None).await; let r = dav( &env, "PROPFIND", &format!("/dav-share/{token}/%2e%2e"), None, b"", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); // The mount itself still works, so this is a refusal and not a breakage. let r = dav(&env, "PROPFIND", &format!("/dav-share/{token}/"), None, b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS); } #[tokio::test] async fn a_read_only_root_refuses_every_write() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "dav-reader", "reader1234", &[("docs", "ro")]).await; let auth = basic("dav-reader", "reader1234"); let r = dav(&env, "GET", "/dav/docs/a.txt", Some(&auth), b"").await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), "file a"); type Case = ( &'static str, &'static str, &'static [(&'static str, &'static str)], ); const CASES: &[Case] = &[ ("PUT", "/dav/docs/new.txt", &[]), ("MKCOL", "/dav/docs/new-dir", &[]), ("DELETE", "/dav/docs/a.txt", &[]), ( "MOVE", "/dav/docs/a.txt", &[("destination", "/dav/docs/b.txt")], ), ]; for (verb, path, extra) in CASES { // A body only for PUT: RFC 4918 says MKCOL with one is a 415, which // would answer before the read-only check ever runs. let body: &[u8] = if *verb == "PUT" { b"body" } else { b"" }; let r = dav_with(&env, verb, path, Some(&auth), extra, body).await; assert_eq!(r.status, StatusCode::FORBIDDEN, "{verb} {path}"); } assert!(env.file("docs/a.txt").exists()); assert!(!env.file("docs/new.txt").exists()); } #[tokio::test] async fn a_read_only_root_can_still_be_copied_out_of() { let env = Env::new().await; let admin = env.admin().await; create_user( &admin, "dav-mixed", "mixed12345", &[("docs", "ro"), ("src", "rw")], ) .await; let auth = basic("dav-mixed", "mixed12345"); // Copying out of a read-only folder into a writable one only writes to the // writable side, so it is allowed. let r = dav_with( &env, "COPY", "/dav/docs/a.txt", Some(&auth), &[("destination", "/dav/src/copied.txt")], b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(env.file("src/copied.txt")).unwrap(), "file a" ); // Moving out of it is not: the source would lose the file. let r = dav_with( &env, "MOVE", "/dav/docs/a.txt", Some(&auth), &[("destination", "/dav/src/moved.txt")], b"", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(env.file("docs/a.txt").exists()); // And the read-only folder still refuses to be the destination. let r = dav_with( &env, "COPY", "/dav/src/main.rs", Some(&auth), &[("destination", "/dav/docs/main.rs")], b"", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(!env.file("docs/main.rs").exists()); } #[tokio::test] async fn a_session_cookie_works_instead_of_basic() { let env = Env::new().await; let admin = env.admin().await; let seg = root_seg(&env); let r = admin .raw( method("PROPFIND"), &format!("/dav/{seg}/"), &[("depth", "1")], Vec::new(), ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); assert!(r.text().contains("notes.md")); } #[tokio::test] async fn a_changed_password_locks_the_mount_out_at_once() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "dav-rotate", "rotate1234", &[("docs", "rw")]).await; let id = user_id(&admin, "dav-rotate").await; let old = basic("dav-rotate", "rotate1234"); let r = dav(&env, "PROPFIND", "/dav/docs/", Some(&old), b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let r = admin .put_json( &format!("/api/admin/users/{id}"), &json!({ "password": "rotated5678" }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); // The old credential was cached a moment ago; it must not survive. let r = dav(&env, "PROPFIND", "/dav/docs/", Some(&old), b"").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); let r = dav( &env, "PROPFIND", "/dav/docs/", Some(&basic("dav-rotate", "rotated5678")), b"", ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS); } // --------------------------------------------------------------------------- // Share mounts // --------------------------------------------------------------------------- async fn share( admin: &Client, path: &str, writable: bool, password: Option<&str>, ) -> (String, i64) { let j = create_share( admin, json!({ "root_id": 1, "path": path, "writable": writable, "password": password, }), ) .await; ( j["token"].as_str().unwrap().to_string(), j["id"].as_i64().unwrap(), ) } #[tokio::test] async fn a_share_mounts_at_its_own_root_without_a_login() { let env = Env::new().await; let admin = env.admin().await; let (token, _) = share(&admin, "docs", false, None).await; let r = dav(&env, "PROPFIND", &format!("/dav-share/{token}/"), None, b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); let body = r.text(); // The share target is the mount root, so its children sit directly under it. assert!( body.contains(&format!("/dav-share/{token}/a.txt")), "{body}" ); assert!( body.contains(&format!("/dav-share/{token}/inner/")), "{body}" ); // Nothing above the share target is reachable. assert!(!body.contains("notes.md"), "{body}"); let r = dav( &env, "GET", &format!("/dav-share/{token}/inner/hello.txt"), None, b"", ) .await; assert_eq!(r.status, StatusCode::OK); assert_eq!(r.text(), "hello world"); // A read-only share stays read-only over WebDAV too. let r = dav( &env, "PUT", &format!("/dav-share/{token}/new.txt"), None, b"x", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); } #[tokio::test] async fn a_protected_share_asks_for_its_password_over_basic() { let env = Env::new().await; let admin = env.admin().await; let (token, _) = share(&admin, "docs", false, Some("sharepass1")).await; let url = format!("/dav-share/{token}/"); let r = dav(&env, "PROPFIND", &url, None, b"").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); assert!(r.header("www-authenticate").is_some()); let r = dav(&env, "PROPFIND", &url, Some(&basic("", "wrong")), b"").await; assert_eq!(r.status, StatusCode::UNAUTHORIZED); // The user name is ignored: a share link has no account behind it. let r = dav( &env, "PROPFIND", &url, Some(&basic("anyone", "sharepass1")), b"", ) .await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{}", r.text()); } #[tokio::test] async fn a_share_password_with_edge_spaces_opens_the_mount() { let env = Env::new().await; let admin = env.admin().await; let (token, _) = share(&admin, "docs", false, Some(" sharepass1 ")).await; let url = format!("/dav-share/{token}/"); for pw in [" sharepass1 ", "sharepass1"] { let r = dav(&env, "PROPFIND", &url, Some(&basic("", pw)), b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS, "{pw:?}: {}", r.text()); } } #[tokio::test] async fn a_writable_share_can_be_written_and_expiry_ends_it() { let env = Env::new().await; let admin = env.admin().await; let r = admin .put_json( "/api/admin/settings", &json!({ "allow_writable_shares": true }), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let (token, _) = share(&admin, "docs", true, None).await; let r = dav( &env, "PUT", &format!("/dav-share/{token}/dropped.txt"), None, b"from a mount", ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); assert_eq!( std::fs::read_to_string(env.file("docs/dropped.txt")).unwrap(), "from a mount" ); // An expired share is gone, not merely empty. let r = admin .post_json( "/api/shares", &json!({ "root_id": 1, "path": "src", "writable": false, "expires_at": "2000-01-01T00:00:00Z", }), ) .await; let dead = r.json()["token"].as_str().unwrap().to_string(); let r = dav(&env, "PROPFIND", &format!("/dav-share/{dead}/"), None, b"").await; assert_eq!(r.status, StatusCode::GONE); // A file share has no collection to mount. let (file_token, _) = share(&admin, "notes.md", false, None).await; let r = dav( &env, "PROPFIND", &format!("/dav-share/{file_token}/"), None, b"", ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND); // An unknown token is a 404, never a hint. let r = dav(&env, "PROPFIND", "/dav-share/deadbeef/", None, b"").await; assert_eq!(r.status, StatusCode::NOT_FOUND); } #[tokio::test] async fn deleting_a_shared_path_over_webdav_revokes_the_share() { let env = Env::new().await; let admin = env.admin().await; let auth = basic("admin", "admin1234"); let seg = root_seg(&env); let (token, _) = share(&admin, "docs/inner", false, None).await; // The share resolves while the folder is there. let r = admin.get(&format!("/api/share/{token}")).await; assert_eq!(r.status, StatusCode::OK); let r = dav( &env, "DELETE", &format!("/dav/{seg}/docs/inner"), Some(&auth), b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); // A share pointing at a path that no longer exists must not linger. let r = admin.get(&format!("/api/share/{token}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text()); // The same for a name that has to be percent-encoded: the lookup decodes // the URL like the delete does, or the link would outlive the file. let r = dav( &env, "PUT", &format!("/dav/{seg}/docs/a%20b.txt"), Some(&auth), b"hi", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); let (token, _) = share(&admin, "docs/a b.txt", false, None).await; let r = dav( &env, "DELETE", &format!("/dav/{seg}/docs/a%20b.txt"), Some(&auth), b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); let r = admin.get(&format!("/api/share/{token}")).await; assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text()); } // --------------------------------------------------------------------------- // Locking // --------------------------------------------------------------------------- const LOCK_BODY: &[u8] = br#" client-one "#; /// Take an exclusive lock and return its token. async fn lock(env: &Env, path: &str, auth: &str) -> (Resp, Option) { let r = dav_with( env, "LOCK", path, Some(auth), &[("timeout", "Second-300")], LOCK_BODY, ) .await; // The token arrives in `Lock-Token: `; the `If:` header wants // it without the angle brackets. let token = r .header("lock-token") .map(|v| v.trim_matches(['<', '>']).to_string()); (r, token) } #[tokio::test] async fn an_exclusive_lock_blocks_everyone_without_the_token() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let path = format!("/dav/{seg}/editme.txt"); let (r, token) = lock(&env, &path, &auth).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let token = token.expect("LOCK must return a Lock-Token header"); assert!(token.starts_with("urn:uuid:"), "token was {token}"); let r = dav(&env, "PUT", &path, Some(&auth), b"from a second client").await; assert_eq!(r.status, StatusCode::LOCKED); assert_eq!( std::fs::read_to_string(env.file("editme.txt")).unwrap(), "v1" ); let r = dav(&env, "DELETE", &path, Some(&auth), b"").await; assert_eq!(r.status, StatusCode::LOCKED); let (r, _) = lock(&env, &path, &auth).await; assert_eq!(r.status, StatusCode::LOCKED); // The holder writes by presenting the token. let r = dav_with( &env, "PUT", &path, Some(&auth), &[("if", &format!("(<{token}>)"))], b"v2 from the holder", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(env.file("editme.txt")).unwrap(), "v2 from the holder" ); let r = dav_with( &env, "UNLOCK", &path, Some(&auth), &[("lock-token", &format!("<{token}>"))], b"", ) .await; assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.text()); let r = dav(&env, "PUT", &path, Some(&auth), b"v3").await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); } #[tokio::test] async fn a_lock_is_reported_and_its_timeout_is_capped() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let path = format!("/dav/{seg}/notes.md"); // No `Timeout` header at all reaches the lock system as "no expiry", which // is the lock nothing can ever sweep. It comes back capped instead. let r = dav_with(&env, "LOCK", &path, Some(&auth), &[], LOCK_BODY).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let body = r.text(); assert!(body.contains("Second-600"), "{body}"); assert!(!body.contains("Infinite"), "{body}"); // PROPFIND must report the lock, or a client cannot see its own. let r = dav_with(&env, "PROPFIND", &path, Some(&auth), &[("depth", "0")], b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let body = r.text(); assert!(body.contains(""), "{body}"); assert!(body.contains("client-one"), "{body}"); } #[tokio::test] async fn locks_are_scoped_to_their_own_path() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let (r, _) = lock(&env, &format!("/dav/{seg}/notes.md"), &auth).await; assert_eq!(r.status, StatusCode::OK); // A lock on one file must not block its neighbours. let r = dav( &env, "PUT", &format!("/dav/{seg}/config.json"), Some(&auth), b"{}", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); } #[tokio::test] async fn an_abandoned_lock_expires() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let path = format!("/dav/{seg}/editme.txt"); // A one-second lock, then no refresh: the client is gone. let r = dav_with( &env, "LOCK", &path, Some(&auth), &[("timeout", "Second-1")], LOCK_BODY, ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let r = dav(&env, "PUT", &path, Some(&auth), b"too early").await; assert_eq!(r.status, StatusCode::LOCKED); tokio::time::sleep(std::time::Duration::from_millis(1200)).await; // Swept on the next request that touches the path. Without the sweep this // file would stay locked until the process restarts. let r = dav(&env, "PUT", &path, Some(&auth), b"after expiry").await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(env.file("editme.txt")).unwrap(), "after expiry" ); } #[tokio::test] async fn concurrent_writers_leave_a_whole_file() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let path = format!("/dav/{seg}/contended.bin"); // Different lengths, so a splice of the two is obvious: it would be // 400_000 bytes long with the shorter body's bytes somewhere inside. let long = vec![b'A'; 400_000]; let short = vec![b'B'; 200_000]; let (a, b) = tokio::join!( dav(&env, "PUT", &path, Some(&auth), &long), dav(&env, "PUT", &path, Some(&auth), &short), ); assert!(a.status.is_success(), "{}", a.status); assert!(b.status.is_success(), "{}", b.status); // Whichever writer landed last, the file is one of the two bodies and not // a mixture. let got = std::fs::read(env.file("contended.bin")).unwrap(); assert!( got == long || got == short, "file is neither body: {} bytes, {} A, {} B", got.len(), got.iter().filter(|&&c| c == b'A').count(), got.iter().filter(|&&c| c == b'B').count(), ); } #[tokio::test] async fn copy_replaces_a_symlink_instead_of_writing_through_it() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; // A symlink inside the root aimed at a file outside it. The app cannot // create one, but anything else with access to the folder can. let outside = env.root.path().parent().unwrap().join("outside.txt"); std::fs::write(&outside, "SECRET").unwrap(); std::os::unix::fs::symlink(&outside, env.file("link.txt")).unwrap(); // `std::fs::copy` follows a destination symlink, so without unlinking it // first the copy lands outside the root with every path check passing. let r = dav_with( &env, "COPY", &format!("/dav/{seg}/notes.md"), Some(&auth), &[("destination", &format!("/dav/{seg}/link.txt"))], b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(&outside).unwrap(), "SECRET", "the copy escaped the root" ); assert_eq!( std::fs::read_to_string(env.file("link.txt")).unwrap(), "# notes" ); assert!( !env.file("link.txt") .symlink_metadata() .unwrap() .file_type() .is_symlink() ); // A link pointing *inside* the root is treated the same way. Following it // would overwrite a file the request never named. std::os::unix::fs::symlink(env.file("config.json"), env.file("inside.txt")).unwrap(); let r = dav_with( &env, "COPY", &format!("/dav/{seg}/notes.md"), Some(&auth), &[("destination", &format!("/dav/{seg}/inside.txt"))], b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(env.file("inside.txt")).unwrap(), "# notes" ); assert_eq!( std::fs::read_to_string(env.file("config.json")).unwrap(), "{\"k\": 1}", "the copy went through the link" ); } #[tokio::test] async fn deleting_a_symlink_removes_the_link_not_its_target() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; std::os::unix::fs::symlink(env.file("notes.md"), env.file("alias.md")).unwrap(); let r = dav( &env, "DELETE", &format!("/dav/{seg}/alias.md"), Some(&auth), b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert!(env.file("alias.md").symlink_metadata().is_err()); assert_eq!( std::fs::read_to_string(env.file("notes.md")).unwrap(), "# notes", "the delete followed the link" ); } #[tokio::test] async fn a_dangling_symlink_is_not_a_writable_destination() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let outside = env.root.path().parent().unwrap().join("never-created.txt"); std::os::unix::fs::symlink(&outside, env.file("dangling.txt")).unwrap(); // It resolves to nothing, so the strict pass reports "not found". Creating // through it would put the file outside the root. let r = dav( &env, "PUT", &format!("/dav/{seg}/dangling.txt"), Some(&auth), b"payload", ) .await; assert_eq!(r.status, StatusCode::FORBIDDEN); assert!(!outside.exists(), "the write escaped the root"); } #[tokio::test] async fn a_copy_and_a_put_to_one_path_do_not_interleave() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; let source = vec![b'S'; 300_000]; std::fs::write(env.file("source.bin"), &source).unwrap(); let put = vec![b'P'; 150_000]; // COPY writes its destination through `fs::copy_file_to`, not through the // same `open()` a PUT uses, so it has to take the write mutex itself. let path = format!("/dav/{seg}/contended.bin"); let src_path = format!("/dav/{seg}/source.bin"); let dest = [("destination", path.as_str())]; let (c, p) = tokio::join!( dav_with(&env, "COPY", &src_path, Some(&auth), &dest, b""), dav(&env, "PUT", &path, Some(&auth), &put), ); assert!(c.status.is_success(), "copy: {}", c.status); assert!(p.status.is_success(), "put: {}", p.status); let got = std::fs::read(env.file("contended.bin")).unwrap(); assert!( got == source || got == put, "file is neither body: {} bytes, {} S, {} P", got.len(), got.iter().filter(|&&c| c == b'S').count(), got.iter().filter(|&&c| c == b'P').count(), ); } #[tokio::test] async fn deleting_a_symlinked_directory_does_not_empty_its_target() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; // A link to a directory, both directly under the mount and nested inside // a folder that gets deleted as a whole. std::fs::create_dir_all(env.file("tree")).unwrap(); std::fs::write(env.file("tree/keep.txt"), "kept").unwrap(); std::os::unix::fs::symlink(env.file("docs"), env.file("linked")).unwrap(); std::os::unix::fs::symlink(env.file("docs"), env.file("tree/linked")).unwrap(); // Directly: `dav-server` asks `symlink_metadata` first, so it sees a link // rather than a collection and never starts a walk. let r = dav( &env, "DELETE", &format!("/dav/{seg}/linked"), Some(&auth), b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert!(env.file("linked").symlink_metadata().is_err()); assert!( env.file("docs/a.txt").exists(), "the delete followed the link" ); // Recursively: the walk asks `read_dir` for unfollowed metadata, so the // nested link is a file to unlink, not a directory to descend into. let r = dav( &env, "DELETE", &format!("/dav/{seg}/tree"), Some(&auth), b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert!(!env.file("tree").exists()); assert!( env.file("docs/a.txt").exists(), "the recursive delete followed the link" ); assert!(env.file("docs/inner/hello.txt").exists()); } #[tokio::test] async fn moving_a_symlinked_directory_moves_the_link() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; std::os::unix::fs::symlink(env.file("docs"), env.file("linked")).unwrap(); // This holds because `fs::move_to` resolves its source as an entry, so // the rename moves the link whatever `dav-server` believed. The honest // `symlink_metadata` only decides the trailing slash on the path here. let r = dav_with( &env, "MOVE", &format!("/dav/{seg}/linked"), Some(&auth), &[("destination", &format!("/dav/{seg}/src/linked"))], b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert!( env.file("src/linked") .symlink_metadata() .unwrap() .file_type() .is_symlink() ); assert!(!env.file("linked").exists()); // `docs` stayed where it was, with its contents. assert!(env.file("docs/a.txt").exists()); } #[tokio::test] async fn a_listing_still_shows_a_symlink_as_its_target() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; // 64 bytes of fixture data behind the link. std::os::unix::fs::symlink(env.file("blob.bin"), env.file("alias.bin")).unwrap(); std::os::unix::fs::symlink(env.file("docs"), env.file("linked")).unwrap(); let r = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS); let body = r.text(); // Followed, so the link reports the target's size, not the link's own. assert!(body.contains("64<"), "{body}"); // And a link to a directory is still a collection, with a trailing slash. assert!(body.contains(&format!("/dav/{seg}/linked/")), "{body}"); assert!(body.contains(&format!("/dav/{seg}/alias.bin")), "{body}"); } // --------------------------------------------------------------------------- // The mount point and a root itself are not deletable // --------------------------------------------------------------------------- #[tokio::test] async fn deleting_the_mount_point_removes_nothing() { let env = Env::new().await; let _ = env.admin().await; let auth = basic("admin", "admin1234"); // `dav-server` deletes a collection's children first and the collection // last, so a refusal that only fires on the final step comes after every // file is already gone. let r = dav(&env, "DELETE", "/dav/", Some(&auth), b"").await; assert!(!r.status.is_success(), "{} {}", r.status, r.text()); for f in [ "notes.md", "docs/a.txt", "docs/inner/hello.txt", "src/main.rs", ] { assert!(env.file(f).exists(), "{f} was deleted"); } } #[tokio::test] async fn deleting_a_root_removes_nothing() { let env = Env::new().await; let admin = env.admin().await; create_user(&admin, "dav-root-del", "rootdel1234", &[("docs", "rw")]).await; let auth = basic("dav-root-del", "rootdel1234"); let r = dav(&env, "DELETE", "/dav/docs", Some(&auth), b"").await; assert!(!r.status.is_success(), "{} {}", r.status, r.text()); assert!(env.file("docs/a.txt").exists()); assert!(env.file("docs/inner/hello.txt").exists()); } #[tokio::test] async fn a_move_cannot_wipe_a_root_through_its_destination() { let env = Env::new().await; let admin = env.admin().await; create_user( &admin, "dav-two-roots", "tworoots1234", &[("docs", "rw"), ("src", "rw")], ) .await; let auth = basic("dav-two-roots", "tworoots1234"); // `Overwrite: T` makes dav-server delete the destination first, and the // destination here is a whole root. let r = dav_with( &env, "MOVE", "/dav/docs", Some(&auth), &[("destination", "/dav/src"), ("overwrite", "T")], b"", ) .await; assert!(!r.status.is_success(), "{} {}", r.status, r.text()); assert!(env.file("src/main.rs").exists(), "the root was wiped"); assert!(env.file("docs/a.txt").exists()); } #[tokio::test] async fn a_scriptable_file_is_sandboxed_over_dav() { let env = Env::new().await; let admin = env.admin().await; let auth = basic("admin", "admin1234"); let seg = root_seg(&env); std::fs::write(env.file("evil.html"), "").unwrap(); // A top-level navigation to this URL carries the session cookie, so the // app's own policy would let the page act as the signed-in user. let r = dav( &env, "GET", &format!("/dav/{seg}/evil.html"), Some(&auth), b"", ) .await; assert_eq!(r.status, StatusCode::OK); let csp = r.header("content-security-policy").unwrap_or_default(); assert!(csp.contains("sandbox allow-scripts"), "policy was: {csp}"); assert!(!csp.contains("allow-same-origin"), "policy was: {csp}"); // Same through a public share, which needs no account at all. let (token, _) = share(&admin, ".", false, None).await; let r = dav( &env, "GET", &format!("/dav-share/{token}/evil.html"), None, b"", ) .await; assert_eq!(r.status, StatusCode::OK); let csp = r.header("content-security-policy").unwrap_or_default(); assert!(csp.contains("sandbox allow-scripts"), "policy was: {csp}"); // A non-scriptable file keeps the app policy; only documents are sandboxed. let r = dav( &env, "GET", &format!("/dav/{seg}/blob.bin"), Some(&auth), b"", ) .await; assert_eq!(r.status, StatusCode::OK); assert!( !r.header("content-security-policy") .unwrap_or_default() .contains("sandbox") ); } #[tokio::test] async fn two_users_with_same_named_roots_do_not_share_locks() { let env = Env::new().await; let admin = env.admin().await; // Different folders, same basename, so both mount at `/dav/Documents`. for owner in ["alpha", "beta"] { std::fs::create_dir_all(env.file(&format!("{owner}/Documents"))).unwrap(); std::fs::write(env.file(&format!("{owner}/Documents/x.txt")), owner).unwrap(); } create_user( &admin, "dav-alpha", "alpha12345", &[("alpha/Documents", "rw")], ) .await; create_user( &admin, "dav-beta", "beta123456", &[("beta/Documents", "rw")], ) .await; let a = basic("dav-alpha", "alpha12345"); let b = basic("dav-beta", "beta123456"); let (r, token) = lock(&env, "/dav/Documents/x.txt", &a).await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let token = token.unwrap(); // Same URL, different user, different file. A shared lock tree would // refuse this with 423. let r = dav(&env, "PUT", "/dav/Documents/x.txt", Some(&b), b"beta wrote").await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert_eq!( std::fs::read_to_string(env.file("beta/Documents/x.txt")).unwrap(), "beta wrote" ); assert_eq!( std::fs::read_to_string(env.file("alpha/Documents/x.txt")).unwrap(), "alpha" ); // And the holder's token is not visible to the other user. let r = dav_with( &env, "PROPFIND", "/dav/Documents/x.txt", Some(&b), &[("depth", "0")], b"", ) .await; assert!(!r.text().contains(&token), "the lock token leaked"); // The holder still owns its own lock. let r = dav(&env, "PUT", "/dav/Documents/x.txt", Some(&a), b"nope").await; assert_eq!(r.status, StatusCode::LOCKED); } #[tokio::test] async fn deleting_a_symlink_does_not_revoke_its_targets_share() { let env = Env::new().await; let admin = env.admin().await; let auth = basic("admin", "admin1234"); let seg = root_seg(&env); std::os::unix::fs::symlink(env.file("notes.md"), env.file("alias.md")).unwrap(); let (token, _) = share(&admin, "notes.md", false, None).await; // The share names `notes.md`. Deleting the link leaves that file in place, // so the share must survive. let r = dav( &env, "DELETE", &format!("/dav/{seg}/alias.md"), Some(&auth), b"", ) .await; assert!(r.status.is_success(), "{} {}", r.status, r.text()); assert!(env.file("notes.md").exists()); let r = admin.get(&format!("/api/share/{token}")).await; assert_eq!( r.status, StatusCode::OK, "the share was revoked: {}", r.text() ); } #[tokio::test] async fn a_dangling_symlink_is_still_listed() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; std::os::unix::fs::symlink(env.file("never-existed"), env.file("dangling.md")).unwrap(); // It has no target to stat. Dropping it from the listing would read to a // sync client as a deletion to mirror, and the JSON API lists it too. let r = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await; assert_eq!(r.status, StatusCode::MULTI_STATUS); assert!(r.text().contains("dangling.md"), "{}", r.text()); } #[tokio::test] async fn a_browser_get_of_a_collection_returns_a_listing() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; // Both the synthetic top level and a real directory answer a plain GET. // Without `autoindex` each would be 405. let top = dav(&env, "GET", "/dav/", Some(&auth), b"").await; assert_eq!(top.status, StatusCode::OK); assert!(top.text().contains("Index of")); let dir = dav(&env, "GET", &format!("/dav/{seg}/docs/"), Some(&auth), b"").await; assert_eq!(dir.status, StatusCode::OK); assert!(dir.text().contains("inner")); // A listing is server-generated HTML, so it still gets the file policy. assert!( dir.header("content-security-policy") .is_some_and(|v| v.contains("sandbox")) ); } /// `dav-server` skips dot-prefixed names when it generates a listing. PROPFIND /// does not, so this only costs visibility in a browser, never a mount. #[tokio::test] async fn a_listing_omits_dotfiles() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; std::fs::write(env.file(".hidden"), "x").unwrap(); let listing = dav(&env, "GET", &format!("/dav/{seg}/"), Some(&auth), b"").await; assert!(!listing.text().contains(".hidden")); let props = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await; assert_eq!(props.status, StatusCode::MULTI_STATUS); assert!(props.text().contains(".hidden")); } #[tokio::test] async fn a_listing_escapes_entry_names() { let env = Env::new().await; let (auth, seg) = admin_dav(&env).await; std::fs::write(env.file(".txt"), "x").unwrap(); let r = dav(&env, "GET", &format!("/dav/{seg}/"), Some(&auth), b"").await; assert_eq!(r.status, StatusCode::OK); let body = r.text(); assert!(body.contains("<img src=x onerror=alert(1)>.txt")); assert!(!body.contains("%2Fx` and then /// hand `dav-server` a prefix its own path does not start with. #[tokio::test] async fn an_encoded_slash_does_not_split_the_share_token() { let env = Env::new().await; let admin = env.admin().await; let (token, _) = share(&admin, "docs", false, None).await; let r = dav( &env, "PROPFIND", &format!("/dav-share/{token}%2Fa.txt"), None, b"", ) .await; assert_eq!(r.status, StatusCode::NOT_FOUND, "{}", r.text()); } #[tokio::test] async fn deep_xml_bodies_are_refused() { let env = Env::new().await; let (auth, _) = admin_dav(&env).await; let body = format!("{}", "".repeat(21_000)); assert!(body.len() <= 65_536); let r = dav(&env, "PROPFIND", "/dav", Some(&auth), body.as_bytes()).await; assert_eq!(r.status, StatusCode::BAD_REQUEST); }