webdav mounts, consistent symlink handling, folder shares
Add a WebDAV endpoint so a file manager can mount the same folders the web UI serves: `/dav` for a user's roots, `/dav-share/<token>` for one public folder share. Both go through `crate::fs`, so a mount inherits the existing containment and permission rules. Auth is HTTP Basic, with a five minute cache so Argon2 does not run on every request. `MemLs` records a lock timeout and never reads it again, so `ExpiringLs` wraps it and sweeps expired locks. Locks also only bind clients that send LOCK, so two plain PUTs to one path used to splice both bodies together. A mutex on the resolved path makes that last-writer-wins. Make symlink handling consistent across the JSON API: an operation acts on the entry named, not on what it points at. `resolve_entry` never follows the last component, so deleting, renaming, or moving a link affects the link. Copying and reading still follow. Fix two containment escapes found on the way. `std::fs::copy` follows a destination symlink, so copying onto a link out of the root wrote outside it. A cross-device move did the same through `copy_recursive`. Frontend: copy buttons for the WebDAV URL, and share in the empty-area context menu, so a folder or a whole root can be shared in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MCargo.lock
@@ -576,6 +576,34 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "dav-server"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88e9e4e7a3546a5b348518694e9f3ed5cf3fc8856e50141c197f54d79b5714a8"
dependencies = [
"bytes",
"chrono",
"derive-where",
"dyn-clone",
"futures-channel",
"futures-util",
"headers",
"htmlescape",
"http",
"http-body",
"http-body-util",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"url",
"uuid",
"xml-rs",
"xmltree",
]
[[package]]
name = "derive-where"
version = "1.6.1"
@@ -626,6 +654,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.18.0"
@@ -1052,6 +1086,30 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1064,6 +1122,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
[[package]]
name = "htmlescape"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]]
name = "http"
version = "1.5.0"
@@ -2243,12 +2307,15 @@ dependencies = [
"api-types",
"argon2",
"axum",
"base64",
"bytes",
"chrono",
"clap",
"dav-server",
"flate2",
"futures-util",
"grep",
"headers",
"http-body-util",
"ignore",
"infer",
@@ -2259,6 +2326,7 @@ dependencies = [
"rust-embed",
"serde",
"serde_json",
"sha2 0.10.9",
"tar",
"tempfile",
"thiserror 2.0.20",
@@ -2268,6 +2336,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"xmltree",
"zip",
"zstd",
]
@@ -2330,6 +2399,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3155,6 +3235,30 @@ dependencies = [
"rustix",
]
[[package]]
name = "xml"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52"
[[package]]
name = "xml-rs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3a56132a0d6ecbe77352edc10232f788fc4ceefefff4cab784a98e0e16b6b51"
dependencies = [
"xml",
]
[[package]]
name = "xmltree"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc04313cab124e498ab1724e739720807b6dc405b9ed0edc5860164d2e4ff70"
dependencies = [
"xml",
]
[[package]]
name = "xxhash-rust"
version = "0.8.18"
MREADME.md
@@ -27,6 +27,8 @@ external services.
read-write folder. An admin setting turns writable shares on or off
globally, and a read-only folder cannot be shared writable. Deleting, renaming, or moving an
item revokes its shares.
- **WebDAV**: mount your folders, or a share, in a file manager. See
[WebDAV](#webdav).
- **UI**: light, dark, or system theme. English, German, and French.
Optional single-click open.
- **Security**: Argon2 password hashes, HttpOnly session cookies with a
@@ -78,6 +80,37 @@ pass `--https`, or set `FILEBROWSER_HTTPS=true` in `compose.yml`. Without the fl
as well. Search uses server-sent events, so the proxy must not buffer
responses on `/api/search`.
## WebDAV
Two mount points. Your permissions are the same as in the web UI.
| URL | Contents | Credentials |
|-----|----------|-------------|
| `https://host/dav` | Every root folder the user has, one collection each | Account name and password |
| `https://host/dav-share/<token>` | One public share | None, or the share password |
Mount it with the file manager you already have:
```bash
# Linux (davfs2)
sudo mount -t davfs https://host/dav /mnt/files
# Linux (GNOME/KDE), macOS Finder: Go → Connect to Server
davs://host/dav
```
Notes:
- Under `/dav` each of your folders is one entry, named as it is in the web UI.
Two folders with the same name get a number added.
- A share of a single file cannot be mounted. Share a folder or open it in the web UI instead.
- A browser opens the same URLs. A folder shows a plain index, a file
downloads. Hidden files are left out of that index but a mount still shows
them.
- If another client has a file locked, your write waits or fails. An abandoned
lock clears after ten minutes, and a server restart clears all of them.
- If two people save the same file at once, the last save wins.
## Development
Requirements: Rust 1.90 or newer with the `wasm32-unknown-unknown` target,
Mapi-types/src/lib.rs
@@ -25,6 +25,14 @@ pub const SHARE_UNLOCK_SUFFIX: &str = "/unlock";
/// `GET /api/search` — name and/or content search, streamed as SSE.
pub const SEARCH: &str = "/api/search";
/// WebDAV mount of the signed-in user's roots: `{DAV}` and `{DAV}/{path...}`.
pub const DAV: &str = "/dav";
/// WebDAV mount of one public share: `{DAV_SHARE}/{token}/{path...}`.
///
/// A separate top-level path, not a segment under [`DAV`]: there, the first
/// segment is a root's display name, which a reserved word could collide with.
pub const DAV_SHARE: &str = "/dav-share";
/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
pub const ADMIN_USERS: &str = "/api/admin/users";
pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
Mserver/Cargo.toml
@@ -13,8 +13,13 @@ api-types = { path = "../api-types" }
anyhow = "1"
argon2 = "0.5"
axum = "0.8"
bytes = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive", "env"] }
# WebDAV protocol only. The bundled `localfs`/`memfs` backends are off: the
# dav filesystem is `api::dav`, which goes through `crate::fs` so that root
# containment, read-only roots and share scoping apply to a mount too.
dav-server = { version = "0.11", default-features = false }
# `default-features = false` drops the `cfb` dependency (compound-document
# detection we do not need) and makes this a zero-dependency crate.
infer = { version = "0.16", default-features = false, features = ["alloc"] }
@@ -24,6 +29,7 @@ rand = "0.8"
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = [
"rt-multi-thread",
@@ -40,7 +46,12 @@ zstd = "0.13"
# Deflate only; the defaults add AES, bzip2, lzma, ppmd, zopfli.
zip = { version = "9.0.0-pre3", default-features = false, features = ["deflate-flate2-zlib-rs"] }
tower-http = { version = "0.6", features = ["trace", "set-header"] }
# Named in the `DavLockSystem` impl. dav-server does not re-export it, so the
# version has to track dav-server's own.
xmltree = "0.12"
futures-util = "0.3"
# Typed `Authorization: Basic` parsing. Already in the tree via dav-server.
headers = "0.4"
tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -48,6 +59,7 @@ ignore = "0.4"
grep = "0.4"
[dev-dependencies]
base64 = "0.22"
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
Mserver/src/api/admin.rs
@@ -189,6 +189,7 @@ pub async fn update_user(
pairs.as_deref(),
)
.await?;
crate::auth::forget_verified();
let updated = state.db.find_user_by_id(id).await?.ok_or_else(|| {
ApiError::localized(
@@ -227,6 +228,7 @@ pub async fn delete_user(
"err_last_admin_delete",
));
}
crate::auth::forget_verified();
if !state.db.delete_user(id).await? {
return Err(ApiError::localized(
StatusCode::NOT_FOUND,
Mserver/src/api/common.rs
@@ -189,8 +189,6 @@ impl HasState for Arc<AppState> {
// Shared validation / naming helpers
// ---------------------------------------------------------------------------
/// Display name for a root path: the file/folder name, or the server root's
/// own name when the path is the whole root (".").
/// UI name of a root or share target `rel` (relative to the server root):
/// its last path component, or the configured root name for `.`.
pub(crate) fn display_name(state: &AppState, rel: &str) -> String {
@@ -207,9 +205,14 @@ pub(crate) fn display_name(state: &AppState, rel: &str) -> String {
/// form `shares.target` is stored in, so share lookups and share revokes both
/// speak the same spelling of a path.
pub(crate) fn target_rel(state: &AppState, abs: &std::path::Path) -> String {
abs.strip_prefix(&state.root)
let rel = abs
.strip_prefix(&state.root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| ".".to_string())
.unwrap_or_else(|_| ".".to_string());
// The server root strips to the empty string, which has no last component
// for [`display_name`] to show. Spell it `.`, the form that already means
// "the whole root" everywhere else.
if rel.is_empty() { ".".to_string() } else { rel }
}
pub(crate) fn validate_account_name(name: &str) -> Result<(), ApiError> {
Aserver/src/api/dav.rs
@@ -0,0 +1,996 @@
//! WebDAV endpoint.
//!
//! Two mounts, both served by the same [`FbFs`]:
//!
//! * `{DAV}` — a signed-in user's roots. Each root is a child collection of a
//! synthetic top-level directory, so one mount covers every root the user
//! has.
//! * `{DAV_SHARE}/{token}` — one public share, mounted at its own root.
//!
//! All filesystem access goes through [`crate::fs`], so a mount inherits the
//! same containment and the same symlink handling the JSON API has.
//!
//! The protocol itself (PROPFIND, the 207 multistatus, `Depth`, `Destination`,
//! `Overwrite`, conditional headers) is `dav-server`'s job. This module only
//! authenticates the request, decides which roots it may see, and maps dav
//! paths onto real ones.
use std::collections::HashMap;
use std::io::SeekFrom;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex, Weak};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use api_types::{DAV, DAV_SHARE, Mode};
use axum::body::Body;
use axum::extract::State;
use axum::http::header::{HeaderMap, WWW_AUTHENTICATE};
use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse;
use bytes::{Buf, Bytes};
use dav_server::DavConfig;
use dav_server::davpath::DavPath;
use dav_server::fs::{
DavDirEntry, DavFile, DavMetaData, FsError, FsFuture, FsResult, FsStream, GuardedFileSystem,
OpenOptions, ReadDirMeta,
};
use dav_server::ls::{DavLock, DavLockSystem, LsFuture};
use dav_server::memls::MemLs;
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use crate::api::common::{display_name, session_auth};
use crate::auth;
use crate::db::RootRow;
use crate::error::AppState;
/// The `WWW-Authenticate` realm. Clients show it in their password prompt.
const REALM: &str = "filebrowser-ng";
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/// `{DAV}` and everything under it: the signed-in user's roots.
///
/// A browser session cookie is accepted, but the usual caller is a mount
/// client, which only speaks HTTP Basic.
pub async fn user(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
let (principal, roots) = match authenticate(&state, req.headers()).await {
Some(v) => v,
None => return challenge(),
};
// The admin pseudo-root (the whole server root, read-only) is deliberately
// not mounted: `session_auth` does not add it, and a mount that silently
// contained a second copy of every other root would be confusing.
let mount = Mount {
roots: Arc::new(root_segments(&state, roots)),
flat: false,
};
serve(state, req, DAV.to_string(), principal, mount).await
}
/// `{DAV_SHARE}/{token}` and everything under it: one public share.
///
/// Folder shares only. A file share has no collection to mount, and its one
/// file is already a plain `GET` away on the share page.
pub async fn share(State(state): State<Arc<AppState>>, req: Request<Body>) -> Response<Body> {
let Some(token) = share_token(req.uri().path()) else {
return StatusCode::NOT_FOUND.into_response();
};
let row = match state.db.share_by_token(&token).await {
Ok(Some(row)) => row,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
if row.is_expired() {
return StatusCode::GONE.into_response();
}
if row.is_file {
return StatusCode::NOT_FOUND.into_response();
}
// A protected share takes its password over Basic, with the user name
// ignored. There is no account behind a share link to name.
if let Some(hash) = row.password_hash.clone() {
let Some((_, password)) = auth::basic_credentials(req.headers()) else {
return challenge();
};
let (pw, id, tok) = (password.clone(), row.id, token.clone());
let ok = auth::verify_cached(row.id, "", &password, move || async move {
// Throttled like `POST /api/share/{token}/unlock`, keyed the same
// way, so a mount client is not the cheap way to guess.
let delay = auth::login_delay(&tok);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let ok = auth::verify_password_async(&pw, &hash).await;
auth::record_login(&tok, ok);
ok.then_some(id)
})
.await;
if ok.is_none() {
return challenge();
}
}
let root = RootRow {
id: row.id,
path: row.target.clone(),
mode: row.mode,
};
let mount = Mount {
roots: Arc::new(vec![(String::new(), root)]),
flat: true,
};
let prefix = format!("{DAV_SHARE}/{token}");
serve(state, req, prefix, format!("share-{}", row.id), mount).await
}
/// The token out of the *raw* URL path.
///
/// Not axum's decoded wildcard: `DavPath` keeps the raw path, and
/// `strip_prefix` byte-compares against it. A decoded `<token>%2Fx` would
/// yield a prefix that the dav path does not start with.
fn share_token(path: &str) -> Option<String> {
let rest = path.strip_prefix(DAV_SHARE)?.strip_prefix('/')?;
let token = rest.split('/').next().unwrap_or_default();
(!token.is_empty()).then(|| token.to_string())
}
/// Hand the request to `dav-server` and, afterwards, keep the share table in
/// step with the filesystem.
///
/// The handler is built here rather than once at startup because `prefix`
/// differs per share mount, and `dav-server` only allows a per-request config
/// override on the unguarded handler. Building it is cheap: an `Arc::new` and
/// two `Arc`-backed trait-object clones.
async fn serve(
state: Arc<AppState>,
req: Request<Body>,
prefix: String,
principal: String,
mount: Mount,
) -> Response<Body> {
// Resolved *before* the operation, while the item still exists: once
// DELETE or MOVE has run there is no path left to look a share up by.
let vacating = matches!(req.method().as_str(), "DELETE" | "MOVE");
let vacated = if vacating {
dav_target(&state, &mount, &prefix, &req)
} else {
None
};
let handler = DavConfig::<Mount>::new()
.filesystem(Box::new(FbFs {
state: state.clone(),
}))
// The handler is rebuilt per request, the lock tree must not be.
.locksystem(Box::new(locks_for(&principal)))
// Its only effect in `dav-server` is picking the `ReadDirMeta` for
// PROPFIND. `false` keeps listings on the followed metadata.
.hide_symlinks(false)
// Off by default in `dav-server`: without it a plain `GET` of any
// collection answers 405, so the mount is unreadable in a browser.
.autoindex(true)
.strip_prefix(prefix)
.build_handler();
let mut resp = handler.handle_guarded(req, principal, mount).await;
crate::api::sandbox_scriptable(&mut resp);
// One revoke for the whole request. Doing it inside the filesystem would
// fire a query per removed item, and a recursive DELETE walks the tree.
if let Some(abs) = vacated
&& resp.status().is_success()
{
crate::api::files::revoke_shares_at(&state, &abs).await;
}
resp.map(Body::new)
}
/// The absolute path a request addresses, if it resolves to one today.
fn dav_target(
state: &AppState,
mount: &Mount,
prefix: &str,
req: &Request<Body>,
) -> Option<PathBuf> {
let mut path = DavPath::from_uri(req.uri()).ok()?;
path.set_prefix(prefix).ok()?;
let (root, rel) = item(&path, mount).ok()?;
// `resolve_entry`, matching the operations this is predicting. Following
// the last component would name a symlink's target, so deleting a link
// would revoke a share on a file that is still there.
crate::fs::resolve_entry(&state.root, &root.path, &rel).ok()
}
/// 401 with the Basic challenge every mount client needs to see before it
/// will send credentials at all.
fn challenge() -> Response<Body> {
(
StatusCode::UNAUTHORIZED,
[(WWW_AUTHENTICATE, format!("Basic realm=\"{REALM}\""))],
)
.into_response()
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
/// Resolve the caller to a principal name and the roots they may mount.
async fn authenticate(state: &AppState, headers: &HeaderMap) -> Option<(String, Vec<RootRow>)> {
// A browser hitting the mount already has a session; take it and skip
// Argon2 entirely.
if auth::parse_session_cookie(headers).is_some()
&& let Ok((user, roots)) = session_auth(headers, state).await
{
return Some((user.name, roots));
}
let (name, password) = auth::basic_credentials(headers)?;
let id = auth::verify_cached(0, &name, &password, || {
let (state, name, password) = (state, name.clone(), password.clone());
async move {
// The login route's throttle, keyed the same way, so guessing over
// WebDAV is no cheaper than guessing over the login form.
let delay = auth::login_delay(&name);
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
let user = state.db.verify_password(&name, &password).await.ok()?;
auth::record_login(&name, user.is_some());
user.map(|u| u.id)
}
})
.await?;
let roots = state.db.user_roots(id).await.ok()?;
Some((name, roots))
}
// ---------------------------------------------------------------------------
// Locking
// ---------------------------------------------------------------------------
/// One lock tree per principal.
///
/// A lock is keyed by DAV URL, and a URL segment is a root's display name, so a
/// single shared tree lets two users whose roots are both named `Documents`
/// reach each other's locks. One could block the other, and `PROPFIND` hands
/// back the holder's lock token, which is enough to release that lock or write
/// through it.
///
/// The cost is that two principals sharing one physical folder do not
/// coordinate through WebDAV locks. Byte-level safety does not rest on this:
/// [`WRITE_LOCKS`] keys on the resolved path and covers every writer.
///
/// `MemLs` keeps its state in an `Arc`, so a clone shares that principal's
/// tree. Locks live in memory only, and a restart drops them all, which is
/// what a client already sees when a lock times out.
static LOCKS: LazyLock<Mutex<HashMap<String, ExpiringLs>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn locks_for(principal: &str) -> ExpiringLs {
let mut g = LOCKS.lock().unwrap_or_else(|e| e.into_inner());
g.entry(principal.to_string())
.or_insert_with(|| ExpiringLs(*MemLs::new()))
.clone()
}
/// Longest lock handed out, and so the longest an abandoned one blocks a file.
/// A client that still wants the file refreshes; one that crashed does not.
///
/// Matches `dav-server`'s own ceiling for an exclusive lock. It caps only the
/// two cases that arrive here uncapped, both as `None` meaning "never
/// expires": a LOCK with no `Timeout` header, and a refresh asking for
/// `Infinite`.
const LOCK_TIMEOUT: Duration = Duration::from_secs(600);
/// [`MemLs`] with lock expiry actually applied.
///
/// `MemLs` records a lock's `timeout_at` and then never looks at it again, and
/// it honours an infinite timeout request. Left alone, a client that died
/// holding an exclusive lock would block that file until the process restarts.
/// Every call here first drops the expired locks covering the path it touches,
/// and no lock is granted for longer than [`LOCK_TIMEOUT`].
#[derive(Debug, Clone)]
struct ExpiringLs(MemLs);
impl ExpiringLs {
/// Drop the expired locks on `path` and its ancestors.
///
/// Only the locks that could block an operation *on this path*. A lock on
/// a descendant is not swept, so a deep operation can still be refused by
/// a stale lock below it until something touches that path directly.
async fn sweep(&self, path: &DavPath) {
let now = SystemTime::now();
for lock in self.0.discover(path).await {
if lock.timeout_at.is_some_and(|t| t <= now) {
let _ = self.0.unlock(&lock.path, &lock.token).await;
}
}
}
/// Never `None`, never longer than [`LOCK_TIMEOUT`]. `None` would mean a
/// lock that [`sweep`](Self::sweep) can never clear.
fn capped(timeout: Option<Duration>) -> Option<Duration> {
Some(timeout.unwrap_or(LOCK_TIMEOUT).min(LOCK_TIMEOUT))
}
}
impl DavLockSystem for ExpiringLs {
fn lock(
&self,
path: &DavPath,
principal: Option<&str>,
owner: Option<&xmltree::Element>,
timeout: Option<Duration>,
shared: bool,
deep: bool,
) -> LsFuture<'_, Result<DavLock, DavLock>> {
// The borrows end with the call, not with the future, so clone into it.
let (path, principal) = (path.clone(), principal.map(str::to_string));
let owner = owner.cloned();
Box::pin(async move {
self.sweep(&path).await;
self.0
.lock(
&path,
principal.as_deref(),
owner.as_ref(),
Self::capped(timeout),
shared,
deep,
)
.await
})
}
fn unlock(&self, path: &DavPath, token: &str) -> LsFuture<'_, Result<(), ()>> {
let (path, token) = (path.clone(), token.to_string());
Box::pin(async move { self.0.unlock(&path, &token).await })
}
fn refresh(
&self,
path: &DavPath,
token: &str,
timeout: Option<Duration>,
) -> LsFuture<'_, Result<DavLock, ()>> {
let (path, token) = (path.clone(), token.to_string());
Box::pin(async move {
// Swept first: a client refreshing a lock it let expire must be
// told, not silently handed the file back.
self.sweep(&path).await;
self.0.refresh(&path, &token, Self::capped(timeout)).await
})
}
fn check(
&self,
path: &DavPath,
principal: Option<&str>,
ignore_principal: bool,
deep: bool,
submitted_tokens: &[String],
) -> LsFuture<'_, Result<(), DavLock>> {
let (path, principal) = (path.clone(), principal.map(str::to_string));
let tokens = submitted_tokens.to_vec();
Box::pin(async move {
self.sweep(&path).await;
self.0
.check(&path, principal.as_deref(), ignore_principal, deep, &tokens)
.await
})
}
fn discover(&self, path: &DavPath) -> LsFuture<'_, Vec<DavLock>> {
let path = path.clone();
Box::pin(async move {
self.sweep(&path).await;
self.0.discover(&path).await
})
}
fn delete(&self, path: &DavPath) -> LsFuture<'_, Result<(), ()>> {
let path = path.clone();
Box::pin(async move { self.0.delete(&path).await })
}
}
/// One mutex per path with a writer on it.
///
/// WebDAV locking does not cover this: a lock is only consulted for a client
/// that sends LOCK, and a plain PUT never does. Two concurrent PUTs otherwise
/// interleave into a byte-level splice of both bodies, with both clients told
/// 2xx. Serializing the write open makes the outcome last-writer-wins.
///
/// Keyed by the resolved absolute path, so two mounts onto the same file share
/// one mutex. The lock tree cannot do that: it keys on the URL, and the same
/// file has a different URL in a user mount and in a share.
static WRITE_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Weak<tokio::sync::Mutex<()>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn write_lock(path: &Path) -> Arc<tokio::sync::Mutex<()>> {
let mut map = WRITE_LOCKS.lock().unwrap_or_else(|e| e.into_inner());
// Drop entries whose last writer finished, so the map holds in-flight
// writes and not every file ever written.
map.retain(|_, w| w.strong_count() > 0);
if let Some(m) = map.get(path).and_then(Weak::upgrade) {
return m;
}
let m = Arc::new(tokio::sync::Mutex::new(()));
map.insert(path.to_path_buf(), Arc::downgrade(&m));
m
}
// ---------------------------------------------------------------------------
// Mount: which roots a request sees, and where in the URL they live
// ---------------------------------------------------------------------------
/// The credentials `dav-server` carries through to [`FbFs`]: the roots this
/// request may touch, and how they are laid out under the mount point.
#[derive(Clone)]
pub struct Mount {
/// URL segment → root. The segment is empty when `flat`.
roots: Arc<Vec<(String, RootRow)>>,
/// One root mounted directly at the mount point (a share), rather than as
/// a child of a synthetic collection.
flat: bool,
}
/// What a dav path addresses.
enum Target {
/// The synthetic collection at the mount point that lists the roots.
Roots,
Item {
root: RootRow,
rel: String,
},
}
/// The URL segment for each root: its display name, disambiguated with the
/// root id when two roots would otherwise claim the same one.
fn root_segments(state: &AppState, roots: Vec<RootRow>) -> Vec<(String, RootRow)> {
let names: Vec<String> = roots.iter().map(|r| display_name(state, &r.path)).collect();
roots
.into_iter()
.zip(&names)
.map(|(r, name)| {
let taken = names.iter().filter(|n| *n == name).count() > 1;
let seg = match taken {
true => format!("{name}-{}", r.id),
false => name.clone(),
};
(seg, r)
})
.collect()
}
fn target(path: &DavPath, mount: &Mount) -> FsResult<Target> {
let rel = path.as_rel_ospath();
if mount.flat {
let (_, root) = mount.roots.first().ok_or(FsError::NotFound)?;
return Ok(Target::Item {
root: root.clone(),
rel: rel.to_string_lossy().into_owned(),
});
}
let mut parts = rel.components();
let Some(first) = parts.next() else {
return Ok(Target::Roots);
};
let seg = first.as_os_str().to_string_lossy();
let (_, root) = mount
.roots
.iter()
.find(|(s, _)| s.as_str() == seg)
.ok_or(FsError::NotFound)?;
Ok(Target::Item {
root: root.clone(),
rel: parts.collect::<PathBuf>().to_string_lossy().into_owned(),
})
}
/// [`target`], rejecting the synthetic collection.
fn item(path: &DavPath, mount: &Mount) -> FsResult<(RootRow, String)> {
match target(path, mount)? {
Target::Roots => Err(FsError::Forbidden),
Target::Item { root, rel } => Ok((root, rel)),
}
}
fn writable(root: &RootRow) -> FsResult<()> {
match root.mode {
Mode::Rw => Ok(()),
Mode::Ro => Err(FsError::Forbidden),
}
}
// ---------------------------------------------------------------------------
// The filesystem
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct FbFs {
state: Arc<AppState>,
}
impl GuardedFileSystem<Mount> for FbFs {
fn open<'a>(
&'a self,
path: &'a DavPath,
options: OpenOptions,
mount: &'a Mount,
) -> FsFuture<'a, Box<dyn DavFile>> {
Box::pin(async move {
let (root, rel) = item(path, mount)?;
if options.write || options.append || options.truncate || options.create {
writable(&root)?;
}
let creating = options.create || options.create_new;
let full = self
.resolve(&root, rel, move |server_root, root_rel, rel| {
use crate::fs::FsError as E;
// Strict first, so a write lands on the file the path
// really names.
match crate::fs::resolve_path(server_root, root_rel, rel) {
Err(E::NotFound) if creating => {
let p = crate::fs::resolve_entry(server_root, root_rel, rel)?;
// Nothing resolved, yet the name is taken: a
// dangling symlink. Opening that with `create`
// would write wherever it points, which may be
// outside the root.
if std::fs::symlink_metadata(&p).is_ok() {
return Err(E::Forbidden);
}
Ok(p)
}
other => other,
}
})
.await?;
// Taken before the open, so the truncate happens under it too,
// and held until the `DavFile` is dropped, which is after the last
// byte of the body has landed.
let writing = options.write || options.append || options.truncate;
let _write = match writing {
true => Some(write_lock(&full).lock_owned().await),
false => None,
};
let file = tokio::fs::OpenOptions::new()
.read(options.read)
.write(options.write)
.append(options.append)
.truncate(options.truncate)
.create(options.create)
.create_new(options.create_new)
.open(&full)
.await
.map_err(|e| io_error(&e))?;
Ok(Box::new(File { file, _write }) as Box<dyn DavFile>)
})
}
/// `meta` decides whether a symlink is described as itself or as what it
/// points at, and `dav-server` picks it per operation: `Data` for a
/// listing, `DataSymlink` for the walk behind a recursive DELETE or COPY.
/// Answering both with followed metadata makes a recursive DELETE descend
/// into a linked directory and empty it.
fn read_dir<'a>(
&'a self,
path: &'a DavPath,
meta: ReadDirMeta,
mount: &'a Mount,
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
Box::pin(async move {
let listing = matches!(meta, ReadDirMeta::Data);
let entries = match target(path, mount)? {
Target::Roots => {
// That walk deletes the children before it asks to remove
// the collection, so refusing the mount point at
// `remove_dir` would come after every root was emptied.
// Refusing the listing stops it before anything is touched.
if !listing {
return Err(FsError::Forbidden);
}
self.root_entries(mount).await
}
Target::Item { root, rel } => {
// Same for a root's own top. It is a mount point, not a
// folder inside one. A whole root cannot be deleted, moved
// onto, or copied through the mount.
if rel.is_empty() && !listing {
return Err(FsError::Forbidden);
}
let full = self.resolve(&root, rel, crate::fs::resolve_path).await?;
// No `MAX_LIST_ENTRIES` cap here, on purpose. PROPFIND has
// no way to say "this listing was cut", so a sync client
// would read a truncated listing as "the rest was deleted"
// and mirror that.
blocking(move || {
let rd = std::fs::read_dir(&full).map_err(|e| io_error(&e))?;
Ok(rd
.flatten()
.filter_map(|e| {
// A link out of the root is still listed. It
// refuses to open.
let meta = match listing {
true => match std::fs::metadata(e.path()) {
Ok(m) => Meta::of(&m),
// No target to stat: a dangling link.
Err(_) => Meta::broken_link(
&std::fs::symlink_metadata(e.path()).ok()?,
),
},
false => Meta::of(&std::fs::symlink_metadata(e.path()).ok()?),
};
Some(Entry {
name: e.file_name().to_string_lossy().into_owned().into_bytes(),
meta,
})
})
.collect())
})
.await?
}
};
let stream = futures_util::stream::iter(
entries
.into_iter()
.map(|e| Ok(Box::new(e) as Box<dyn DavDirEntry>)),
);
Ok(Box::pin(stream) as FsStream<Box<dyn DavDirEntry>>)
})
}
fn metadata<'a>(
&'a self,
path: &'a DavPath,
mount: &'a Mount,
) -> FsFuture<'a, Box<dyn DavMetaData>> {
Box::pin(async move {
let (root, rel) = match target(path, mount)? {
Target::Roots => return Ok(Box::new(Meta::synthetic_dir()) as Box<dyn DavMetaData>),
Target::Item { root, rel } => (root, rel),
};
let full = self.resolve(&root, rel, crate::fs::resolve_path).await?;
let meta = blocking(move || std::fs::metadata(&full).map_err(|e| io_error(&e))).await?;
Ok(Box::new(Meta::of(&meta)) as Box<dyn DavMetaData>)
})
}
/// Metadata of the entry itself. `dav-server` asks this before a DELETE,
/// a MOVE, and before overwriting a destination, precisely so it can act
/// on a link rather than on what it names.
fn symlink_metadata<'a>(
&'a self,
path: &'a DavPath,
mount: &'a Mount,
) -> FsFuture<'a, Box<dyn DavMetaData>> {
Box::pin(async move {
let (root, rel) = match target(path, mount)? {
Target::Roots => return Ok(Box::new(Meta::synthetic_dir()) as Box<dyn DavMetaData>),
Target::Item { root, rel } => (root, rel),
};
let full = self.resolve(&root, rel, crate::fs::resolve_entry).await?;
let meta = blocking(move || std::fs::symlink_metadata(&full).map_err(|e| io_error(&e)))
.await?;
Ok(Box::new(Meta::of(&meta)) as Box<dyn DavMetaData>)
})
}
fn create_dir<'a>(&'a self, path: &'a DavPath, mount: &'a Mount) -> FsFuture<'a, ()> {
Box::pin(async move {
let (root, rel) = item(path, mount)?;
writable(&root)?;
let (server_root, root_rel) = (self.state.root.clone(), root.path.clone());
blocking(move || crate::fs::mkdir(&server_root, &root_rel, &rel).map_err(fs_error))
.await
})
}
/// Only ever called on an empty directory: `dav-server` walks a tree
/// itself and removes the children first.
fn remove_dir<'a>(&'a self, path: &'a DavPath, mount: &'a Mount) -> FsFuture<'a, ()> {
Box::pin(async move {
let (root, rel) = item(path, mount)?;
writable(&root)?;
let full = self.resolve(&root, rel, crate::fs::resolve_entry).await?;
blocking(move || {
// A symlink to a directory is listed as a collection, so this
// is where DELETE lands on one. Unlink it rather than letting
// `remove_dir` fail on a path that is not a directory.
let meta = std::fs::symlink_metadata(&full).map_err(|e| io_error(&e))?;
match meta.file_type().is_symlink() {
true => std::fs::remove_file(&full),
false => std::fs::remove_dir(&full),
}
.map_err(|e| io_error(&e))
})
.await
})
}
fn remove_file<'a>(&'a self, path: &'a DavPath, mount: &'a Mount) -> FsFuture<'a, ()> {
Box::pin(async move {
let (root, rel) = item(path, mount)?;
writable(&root)?;
// Not followed: deleting a symlink removes the link, not the file
// it names.
let full = self.resolve(&root, rel, crate::fs::resolve_entry).await?;
blocking(move || std::fs::remove_file(&full).map_err(|e| io_error(&e))).await
})
}
fn rename<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
mount: &'a Mount,
) -> FsFuture<'a, ()> {
Box::pin(async move {
let (src, dst) = (item(from, mount)?, item(to, mount)?);
// A move takes the item out of the source root, so that root has
// to be writable too.
writable(&src.0)?;
writable(&dst.0)?;
let server_root = self.state.root.clone();
blocking(move || {
crate::fs::move_to(&server_root, &src.0.path, &src.1, &dst.0.path, &dst.1)
.map_err(fs_error)
})
.await
})
}
/// Files only: `dav-server` walks a directory tree itself.
fn copy<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
mount: &'a Mount,
) -> FsFuture<'a, ()> {
Box::pin(async move {
let (src, dst) = (item(from, mount)?, item(to, mount)?);
// Only the destination is written. Copying *out of* a read-only
// root is fine, and is how a user gets a read-only folder's
// contents into a writable one.
writable(&dst.0)?;
// The same mutex a PUT to this path would take, or a COPY and a
// PUT racing for it interleave. Both resolve to the canonical
// parent plus the name, so the keys agree.
let full = self
.resolve(&dst.0, dst.1.clone(), crate::fs::resolve_entry)
.await?;
let _write = write_lock(&full).lock_owned().await;
let server_root = self.state.root.clone();
blocking(move || {
crate::fs::copy_file_to(&server_root, &src.0.path, &src.1, &dst.0.path, &dst.1)
.map_err(fs_error)
})
.await
})
}
}
impl FbFs {
/// Run one of the [`crate::fs`] resolvers on the blocking pool.
async fn resolve(
&self,
root: &RootRow,
rel: String,
f: impl FnOnce(&std::path::Path, &str, &str) -> Result<PathBuf, crate::fs::FsError>
+ Send
+ 'static,
) -> FsResult<PathBuf> {
let (server_root, root_rel) = (self.state.root.clone(), root.path.clone());
blocking(move || f(&server_root, &root_rel, &rel).map_err(fs_error)).await
}
/// The synthetic top-level listing: one entry per mounted root.
async fn root_entries(&self, mount: &Mount) -> Vec<Entry> {
let mut out = Vec::with_capacity(mount.roots.len());
for (seg, root) in mount.roots.iter() {
// A root that no longer resolves is skipped rather than reported
// as broken: the JSON API hides it the same way.
let (server_root, root_rel) = (self.state.root.clone(), root.path.clone());
let Ok(full) = blocking(move || {
crate::fs::resolve_root(&server_root, &root_rel).map_err(fs_error)
})
.await
else {
continue;
};
let meta = tokio::fs::metadata(&full)
.await
.map(|m| Meta::of(&m))
.unwrap_or_else(|_| Meta::synthetic_dir());
out.push(Entry {
name: seg.clone().into_bytes(),
meta,
});
}
out
}
}
// ---------------------------------------------------------------------------
// Filesystem value types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct Meta {
len: u64,
modified: SystemTime,
is_dir: bool,
is_symlink: bool,
}
impl Meta {
fn of(m: &std::fs::Metadata) -> Self {
Meta {
len: m.len(),
modified: m.modified().unwrap_or(UNIX_EPOCH),
is_dir: m.is_dir(),
is_symlink: m.file_type().is_symlink(),
}
}
/// A listing entry whose target could not be stat'd: a dangling symlink.
///
/// Described as an empty file, not as a link: `dav-server` drops any entry
/// a listing reports as a symlink, and a sync client reads a file missing
/// from PROPFIND as a deletion to mirror.
fn broken_link(m: &std::fs::Metadata) -> Self {
Meta {
len: 0,
is_dir: false,
is_symlink: false,
..Meta::of(m)
}
}
/// The mount point itself, which is not a directory on disk.
fn synthetic_dir() -> Self {
Meta {
len: 0,
modified: UNIX_EPOCH,
is_dir: true,
is_symlink: false,
}
}
}
impl DavMetaData for Meta {
fn len(&self) -> u64 {
self.len
}
fn modified(&self) -> FsResult<SystemTime> {
Ok(self.modified)
}
fn is_dir(&self) -> bool {
self.is_dir
}
fn is_symlink(&self) -> bool {
self.is_symlink
}
}
#[derive(Debug)]
struct Entry {
name: Vec<u8>,
meta: Meta,
}
impl DavDirEntry for Entry {
fn name(&self) -> Vec<u8> {
self.name.clone()
}
fn metadata(&self) -> FsFuture<'_, Box<dyn DavMetaData>> {
let meta = self.meta.clone();
Box::pin(std::future::ready(Ok(
Box::new(meta) as Box<dyn DavMetaData>
)))
}
}
/// An open file. Plain async I/O: the path was already resolved and checked,
/// so nothing here needs the blocking pool.
#[derive(Debug)]
struct File {
file: tokio::fs::File,
/// Held for the life of a writable handle. See [`WRITE_LOCKS`].
_write: Option<tokio::sync::OwnedMutexGuard<()>>,
}
/// Ceiling on one `read_bytes` allocation. `dav-server` asks for its own read
/// buffer size, but the count reaches us from the request, and a short read is
/// always a valid answer.
const MAX_READ: usize = 64 * 1024;
impl DavFile for File {
fn metadata(&mut self) -> FsFuture<'_, Box<dyn DavMetaData>> {
Box::pin(async move {
let m = self.file.metadata().await.map_err(|e| io_error(&e))?;
Ok(Box::new(Meta::of(&m)) as Box<dyn DavMetaData>)
})
}
fn write_buf(&mut self, mut buf: Box<dyn Buf + Send>) -> FsFuture<'_, ()> {
Box::pin(async move {
while buf.has_remaining() {
let n = self
.file
.write(buf.chunk())
.await
.map_err(|e| io_error(&e))?;
buf.advance(n);
}
Ok(())
})
}
fn write_bytes(&mut self, buf: Bytes) -> FsFuture<'_, ()> {
Box::pin(async move { self.file.write_all(&buf).await.map_err(|e| io_error(&e)) })
}
fn read_bytes(&mut self, count: usize) -> FsFuture<'_, Bytes> {
Box::pin(async move {
let mut b = vec![0u8; count.min(MAX_READ)];
let n = self.file.read(&mut b).await.map_err(|e| io_error(&e))?;
b.truncate(n);
Ok(Bytes::from(b))
})
}
fn seek(&mut self, pos: SeekFrom) -> FsFuture<'_, u64> {
Box::pin(async move { self.file.seek(pos).await.map_err(|e| io_error(&e)) })
}
fn flush(&mut self) -> FsFuture<'_, ()> {
Box::pin(async move { self.file.flush().await.map_err(|e| io_error(&e)) })
}
}
// ---------------------------------------------------------------------------
// Errors and blocking work
// ---------------------------------------------------------------------------
/// Run blocking filesystem work, mapping a panic or a shut-down runtime onto
/// a 500.
async fn blocking<T: Send + 'static>(
f: impl FnOnce() -> FsResult<T> + Send + 'static,
) -> FsResult<T> {
tokio::task::spawn_blocking(f)
.await
.map_err(|_| FsError::GeneralFailure)?
}
fn fs_error(e: crate::fs::FsError) -> FsError {
use crate::fs::FsError as E;
match e {
E::NotFound | E::RootMissing => FsError::NotFound,
E::Conflict => FsError::Exists,
// `Invalid` is a rejected name, which is a refusal, not a 400 here:
// WebDAV has no status for "that name is not allowed".
E::NotADirectory | E::Forbidden | E::Invalid(_) => FsError::Forbidden,
}
}
/// `dav-server` only derives this from `std::io::Error` when its own `localfs`
/// backend is compiled in, which it is not.
fn io_error(e: &std::io::Error) -> FsError {
use std::io::ErrorKind as K;
match e.kind() {
K::NotFound => FsError::NotFound,
K::PermissionDenied => FsError::Forbidden,
K::AlreadyExists => FsError::Exists,
K::CrossesDevices => FsError::IsRemote,
// `read_dir` on a file. A refusal, not a server fault.
K::NotADirectory => FsError::Forbidden,
_ => FsError::GeneralFailure,
}
}
Mserver/src/api/files.rs
@@ -1170,7 +1170,7 @@ fn validate_rel_path(name: &str) -> Result<(), ApiError> {
/// read that as "the delete failed" and retry, and the retry would 404. The
/// failure is logged at `error` instead, and leaves a share pointing at a
/// path that no longer holds what it did.
async fn revoke_shares_at(state: &AppState, abs: &std::path::Path) {
pub(crate) async fn revoke_shares_at(state: &AppState, abs: &std::path::Path) {
let target = target_rel(state, abs);
match state.db.revoke_shares_at(&target).await {
Ok(0) => {}
Mserver/src/api/mod.rs
@@ -1,12 +1,12 @@
use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SEARCH,
SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, DAV, DAV_SHARE,
FILES, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
use axum::routing::{delete, get, post, put};
use axum::routing::{any, delete, get, post, put};
use tower_http::set_header::SetResponseHeaderLayer;
use crate::error::AppState;
@@ -44,6 +44,26 @@ const CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-u
/// access to this server.
pub(crate) const FILE_CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src *; object-src 'none'; frame-ancestors 'none'; sandbox allow-scripts allow-forms allow-modals allow-downloads allow-popups allow-top-navigation-by-user-activation;";
/// Apply [`FILE_CSP`] to a response that declares a scriptable type.
///
/// The router's CSP layer is `if_not_present`, so without this such a response
/// keeps the *app* policy: same origin, scripts allowed. A `GET` of a shared
/// HTML file is a top-level navigation, which carries the session cookie under
/// `SameSite=Lax`, so the page would then run as the viewer against `/api`.
pub(crate) fn sandbox_scriptable<B>(resp: &mut axum::http::Response<B>) {
let scriptable = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(is_scriptable_mime);
if scriptable {
resp.headers_mut().insert(
"content-security-policy",
HeaderValue::from_static(FILE_CSP),
);
}
}
/// Content-Security-Policy for inline (preview) files that are *not*
/// scripting documents (PDF, media, …): the preview modal embeds them in a
/// same-origin `<iframe>`. Scriptable files never get this — see [`FILE_CSP`].
@@ -67,6 +87,7 @@ pub(crate) fn is_scriptable_mime(mime: &str) -> bool {
mod admin;
mod auth;
mod common;
mod dav;
mod files;
mod search;
mod shares;
@@ -81,6 +102,12 @@ pub fn router(state: Arc<AppState>) -> Router {
let share_token = format!("{SHARE}/{{token}}");
let share_unlock = format!("{SHARE}/{{token}}{SHARE_UNLOCK_SUFFIX}");
let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
// A wildcard needs something to capture, so `/dav/` gets its own pattern:
// mount clients ask for it with the trailing slash, which matches neither
// the bare path nor `{*path}`.
let dav_root = format!("{DAV}/");
let dav_item = format!("{DAV}/{{*path}}");
let dav_share = format!("{DAV_SHARE}/{{*path}}");
Router::new()
.route(AUTH_LOGIN, post(auth::login))
@@ -105,6 +132,13 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(&admin_user_id, delete(admin::delete_user))
.route(ADMIN_SETTINGS, get(admin::get_settings))
.route(ADMIN_SETTINGS, put(admin::update_settings))
// `any`, not a method filter: WebDAV's verbs (PROPFIND, MKCOL, MOVE, …)
// are not in axum's `MethodFilter`, and a method router's fallback
// takes every one of them.
.route(DAV, any(dav::user))
.route(&dav_root, any(dav::user))
.route(&dav_item, any(dav::user))
.route(&dav_share, any(dav::share))
.fallback(spa::fallback)
// The editor save body is checked against `MAX_TEXT_BYTES` in the
// handler; axum's default limit is the same 2 MiB, which would win
Mserver/src/auth.rs
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use argon2::Argon2;
@@ -20,6 +21,19 @@ pub fn hash_password(password: &str) -> anyhow::Result<String> {
Ok(hash.to_string())
}
/// [`verify_password`] off the async executor, behind [`ARGON2_SLOTS`].
///
/// Argon2 is slow and memory-hungry by design, so it must not run on a tokio
/// worker. A join failure means the task panicked or the runtime is shutting
/// down; either way nothing was verified, so the answer is `false`.
pub async fn verify_password_async(password: &str, hash: &str) -> bool {
let (password, hash) = (password.to_string(), hash.to_string());
let _slot = ARGON2_SLOTS.acquire().await;
tokio::task::spawn_blocking(move || verify_password(&password, &hash))
.await
.unwrap_or(false)
}
pub fn verify_password(password: &str, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
@@ -53,16 +67,15 @@ fn hex_token(bytes: usize) -> String {
/// Failed logins per name within the last [`FAILURE_WINDOW`].
/// ponytail: process-wide map, keyed by name. Enough to blunt online guessing
/// on a single-node deployment; move to the DB if the server is ever scaled out.
static LOGIN_FAILURES: std::sync::Mutex<Option<HashMap<String, (u32, Instant)>>> =
std::sync::Mutex::new(None);
static LOGIN_FAILURES: LazyLock<std::sync::Mutex<HashMap<String, (u32, Instant)>>> =
LazyLock::new(Default::default);
const FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60);
/// How long a login attempt for `name` must wait before it is checked: 0 for
/// the first few tries, then growing per failure, capped at a few seconds. A
/// delay rather than a lockout, so an attacker cannot lock a real user out.
pub fn login_delay(name: &str) -> Duration {
let mut g = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
let map = g.get_or_insert_with(HashMap::new);
let map = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
match map.get(&name.to_lowercase()) {
Some((n, at)) if at.elapsed() < FAILURE_WINDOW => delay_for(*n),
_ => Duration::ZERO,
@@ -70,8 +83,7 @@ pub fn login_delay(name: &str) -> Duration {
}
pub fn record_login(name: &str, ok: bool) {
let mut g = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
let map = g.get_or_insert_with(HashMap::new);
let mut map = LOGIN_FAILURES.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (_, at)| at.elapsed() < FAILURE_WINDOW);
let key = name.to_lowercase();
if ok {
@@ -86,6 +98,89 @@ fn delay_for(failures: u32) -> Duration {
Duration::from_millis(500 * u64::from(failures.saturating_sub(2)).min(10))
}
// ---------------------------------------------------------------------------
// Verified HTTP Basic credentials
// ---------------------------------------------------------------------------
/// How long a verified Basic credential is trusted without re-running Argon2.
///
/// Only the WebDAV mount uses Basic, and a mount client re-sends the header on
/// every request. Each verify costs ~100 ms and one of the four
/// [`ARGON2_SLOTS`], which real logins queue for too.
const VERIFIED_TTL: Duration = Duration::from_secs(300);
type CredCache = HashMap<[u8; 32], (i64, Instant)>;
/// Verified credentials: keyed hash of the credential → (subject id, when it
/// was verified).
///
/// ponytail: process-wide map like [`LOGIN_FAILURES`]; move it to the DB if
/// the server is ever scaled out.
static VERIFIED: LazyLock<std::sync::Mutex<CredCache>> = LazyLock::new(Default::default);
/// Look a credential up in the cache, falling back to `verify`, which returns
/// the subject id on success.
///
/// `realm` separates the key spaces: `0` for accounts, a share id for that
/// share's password, so a share password can never satisfy an account lookup.
pub async fn verify_cached<F, Fut>(realm: i64, name: &str, password: &str, verify: F) -> Option<i64>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Option<i64>>,
{
let key = cache_key(realm, name, password);
{
let mut map = VERIFIED.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (_, at)| at.elapsed() < VERIFIED_TTL);
if let Some((id, _)) = map.get(&key) {
return Some(*id);
}
}
let id = verify().await?;
VERIFIED
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key, (id, Instant::now()));
Some(id)
}
/// Drop every cached credential.
///
/// Called whenever an account's password, active flag or roots change. Without
/// it a changed password would keep working on an open mount until the entry
/// aged out.
pub fn forget_verified() {
VERIFIED.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
/// A keyed hash of the credential, never the credential itself. The pepper is
/// fresh per process, so a dump of the map alone yields no passwords.
fn cache_key(realm: i64, name: &str, password: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
static PEPPER: LazyLock<[u8; 32]> = LazyLock::new(|| {
use rand::RngCore;
let mut b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut b);
b
});
let mut h = Sha256::new();
h.update(*PEPPER);
h.update(realm.to_le_bytes());
// Length-prefixed, so ("ab", "c") and ("a", "bc") cannot collide.
h.update((name.len() as u64).to_le_bytes());
h.update(name.as_bytes());
h.update(password.as_bytes());
h.finalize().into()
}
/// Parse `Authorization: Basic <base64(name:password)>`.
pub fn basic_credentials(headers: &axum::http::HeaderMap) -> Option<(String, String)> {
use headers::HeaderMapExt as _;
use headers::authorization::{Authorization, Basic};
let auth = headers.typed_get::<Authorization<Basic>>()?;
Some((auth.username().to_string(), auth.password().to_string()))
}
pub fn session_cookie(token: &str, https: bool) -> String {
let mut c =
format!("{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}");
Mserver/src/db.rs
@@ -265,13 +265,7 @@ impl Db {
}
_ => (None, DUMMY_HASH.clone()),
};
// Argon2 is CPU-bound, so it must not run on an async worker thread.
let _slot = crate::auth::ARGON2_SLOTS.acquire().await;
let password = password.to_string();
let ok =
tokio::task::spawn_blocking(move || crate::auth::verify_password(&password, &hash))
.await
.unwrap_or(false);
let ok = crate::auth::verify_password_async(password, &hash).await;
let Some((id, name, is_admin, single_click, language)) = row else {
return Ok(None);
};
Mserver/src/fs.rs
@@ -94,6 +94,19 @@ pub fn resolve_file(server_root: &Path, rel: &str) -> Result<PathBuf, FsError> {
Ok(full)
}
/// Whether the entry at `p` is itself a directory. A symlink is not, however
/// its target looks: an operation on the name must not recurse into a tree the
/// request never named.
fn entry_is_dir(p: &Path) -> bool {
std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir())
}
/// Whether a name is taken. Unlike `Path::exists`, a dangling symlink counts:
/// it still occupies the name.
fn entry_exists(p: &Path) -> bool {
std::fs::symlink_metadata(p).is_ok()
}
fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
if p == base || p.starts_with(base) {
Ok(())
@@ -380,7 +393,7 @@ fn validate_component(name: &str) -> Result<(), FsError> {
/// Create a directory (and any missing parents) inside a user root.
pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
let full = resolve_path_or_new(server_root, root_rel, req_rel)?;
let full = resolve_entry(server_root, root_rel, req_rel)?;
if full.exists() {
return Err(FsError::Conflict);
}
@@ -390,7 +403,7 @@ pub fn mkdir(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), Fs
/// Create an empty file. The parent must exist; the file must not.
pub fn create_file(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<(), FsError> {
let full = resolve_path_or_new(server_root, root_rel, req_rel)?;
let full = resolve_entry(server_root, root_rel, req_rel)?;
// create_new = O_EXCL: fails on an existing path, does not follow a symlink.
std::fs::File::create_new(&full).map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => FsError::Conflict,
@@ -399,8 +412,17 @@ pub fn create_file(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<
Ok(())
}
/// Resolve a path that does not need to exist yet, but whose *parent* must.
fn resolve_path_or_new(
/// Resolve a path that names an *entry*, not the file that entry may point at.
///
/// The last component is never followed and need not exist. The parent must,
/// and is canonicalized and checked against the root.
///
/// This is the resolver for operations that act on the name: create, delete,
/// rename, the source of a move, the destination of a move or copy. Following
/// a symlink there would delete a file the request never mentioned, or rename
/// one into a different directory. Reads and content writes use
/// [`resolve_path`] instead and do follow, contained by `ensure_within`.
pub(crate) fn resolve_entry(
server_root: &Path,
root_rel: &str,
req_rel: &str,
@@ -420,7 +442,13 @@ fn resolve_path_or_new(
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
let parent = parent.canonicalize().map_err(|e| io_err(e, parent))?;
ensure_within(&root_abs, &parent)?;
Ok(full)
// Re-join onto the *canonical* parent. `full` may still spell a symlinked
// directory, and a caller comparing it against another resolved path (see
// [`move_to`]) would then compare two different spellings of one place.
let name = full
.file_name()
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
Ok(parent.join(name))
}
/// Rename (or move within the same directory) an item.
@@ -434,7 +462,7 @@ pub fn rename_item(
overwrite: bool,
) -> Result<PathBuf, FsError> {
validate_component(new_name)?;
let from = resolve_path(server_root, root_rel, req_rel)?;
let from = resolve_entry(server_root, root_rel, req_rel)?;
let parent = from
.parent()
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
@@ -445,7 +473,7 @@ pub fn rename_item(
return Ok(to);
}
// `rename` replaces a file target atomically; no remove-then-rename gap.
if to.exists() && (!overwrite || to.is_dir() || from.is_dir()) {
if entry_exists(&to) && (!overwrite || entry_is_dir(&to) || entry_is_dir(&from)) {
return Err(FsError::Conflict);
}
std::fs::rename(&from, &to).map_err(|e| io_err(e, &to))?;
@@ -459,8 +487,10 @@ pub fn remove_item(
root_rel: &str,
req_rel: &str,
) -> Result<(bool, PathBuf), FsError> {
let full = resolve_path(server_root, root_rel, req_rel)?;
let is_dir = full.is_dir();
let full = resolve_entry(server_root, root_rel, req_rel)?;
// A symlink is unlinked, never followed: deleting it must not delete the
// file it names. A dangling link is deletable for the same reason.
let is_dir = entry_is_dir(&full);
if is_dir {
std::fs::remove_dir_all(&full).map_err(|e| io_err(e, &full))?;
} else {
@@ -541,7 +571,7 @@ pub fn move_item(
dst_dir_rel: &str,
overwrite: bool,
) -> Result<PathBuf, FsError> {
let from = resolve_path(server_root, src_root_rel, src_rel)?;
let from = resolve_entry(server_root, src_root_rel, src_rel)?;
let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
let name = from
.file_name()
@@ -556,26 +586,14 @@ pub fn move_item(
}
// Refuse moving a directory into itself or a descendant.
if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
if entry_is_dir(&from) && is_within_or_eq(&from, &dst_dir) {
return Err(FsError::Invalid(
"cannot move a folder into itself".to_string(),
));
}
check_move_conflict(&to, &from, overwrite)?;
match std::fs::rename(&from, &to) {
Ok(()) => Ok(from),
Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
copy_recursive(&from, &to)?;
if from.is_dir() {
std::fs::remove_dir_all(&from).map_err(|_| FsError::Forbidden)?;
} else {
std::fs::remove_file(&from).map_err(|_| FsError::Forbidden)?;
}
Ok(from)
}
Err(e) => Err(io_err(e, &to)),
}
rename_or_copy(&from, &to)?;
Ok(from)
}
/// Copy an item (possibly across roots).
@@ -587,9 +605,13 @@ pub fn copy_item(
dst_dir_rel: &str,
overwrite: bool,
) -> Result<(), FsError> {
// Two resolutions of one path, because a copy of a symlink wants both
// halves: the bytes of the file it names, under the name of the link
// itself. `cp` does the same.
let from = resolve_path(server_root, src_root_rel, src_rel)?;
let entry = resolve_entry(server_root, src_root_rel, src_rel)?;
let dst_dir = resolve_dir(server_root, dst_root_rel, dst_dir_rel)?;
let name = from
let name = entry
.file_name()
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?
.to_owned();
@@ -610,13 +632,117 @@ pub fn copy_item(
Ok(())
}
/// Move an item to an explicit destination path (the WebDAV `MOVE` shape).
///
/// Unlike [`move_item`], the destination names the item itself, so this also
/// renames. There is no overwrite check here: the WebDAV layer has already
/// refused, or deleted, an existing destination by the time this runs.
pub fn move_to(
server_root: &Path,
src_root_rel: &str,
src_rel: &str,
dst_root_rel: &str,
dst_rel: &str,
) -> Result<(), FsError> {
// An entry, not its target: moving a symlink moves the link.
let from = resolve_entry(server_root, src_root_rel, src_rel)?;
let to = resolve_dest(server_root, &from, dst_root_rel, dst_rel)?;
if from == to {
return Ok(());
}
rename_or_copy(&from, &to)
}
/// `rename`, falling back to copy-then-remove when the two paths are on
/// different filesystems.
///
/// A symlink is refused on the fallback path. `copy_recursive` stats with
/// `metadata`, which follows, so it would replace the link with a copy of its
/// target, and that target may be outside the root. Plain `rename` moves the
/// link itself and needs no such guard.
fn rename_or_copy(from: &Path, to: &Path) -> Result<(), FsError> {
match std::fs::rename(from, to) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
if std::fs::symlink_metadata(from).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(FsError::Forbidden);
}
copy_recursive(from, to)?;
if entry_is_dir(from) {
std::fs::remove_dir_all(from).map_err(|e| io_err(e, from))?;
} else {
std::fs::remove_file(from).map_err(|e| io_err(e, from))?;
}
Ok(())
}
Err(e) => Err(io_err(e, to)),
}
}
/// Copy to an explicit destination path (the WebDAV `COPY` shape). The WebDAV
/// layer only ever asks for a single file: it walks a tree itself, one
/// `create_dir` and one `copy` per entry.
pub fn copy_file_to(
server_root: &Path,
src_root_rel: &str,
src_rel: &str,
dst_root_rel: &str,
dst_rel: &str,
) -> Result<(), FsError> {
// The source *is* followed: a copy wants the file's bytes, the way `cp`
// does. `resolve_path` still refuses a link that leaves the root.
let from = resolve_path(server_root, src_root_rel, src_rel)?;
let to = resolve_dest(server_root, &from, dst_root_rel, dst_rel)?;
if from == to {
return Ok(());
}
copy_recursive(&from, &to)
}
/// Copy one file, replacing a symlink at the destination instead of writing
/// through it.
///
/// `std::fs::copy` follows a destination symlink, so a link out of the root
/// makes the copy land outside it with every path check passing. Every caller
/// here has already decided the destination may be overwritten, so unlinking
/// first is also the semantics they wanted. `rename` needs no such guard; it
/// replaces the link rather than following it.
fn copy_file(src: &Path, dst: &Path) -> Result<(), FsError> {
if std::fs::symlink_metadata(dst).is_ok_and(|m| m.file_type().is_symlink()) {
std::fs::remove_file(dst).map_err(|e| io_err(e, dst))?;
}
std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?;
Ok(())
}
/// Resolve the destination of a move or copy that names it in full.
///
/// A destination *inside* `from` is refused, which is what stops
/// `MOVE /a /a/b` from eating itself. The source is the caller's to resolve: a
/// move relocates the entry, a copy wants the bytes, so the two follow a
/// symlink differently.
fn resolve_dest(
server_root: &Path,
from: &Path,
dst_root_rel: &str,
dst_rel: &str,
) -> Result<PathBuf, FsError> {
let to = resolve_entry(server_root, dst_root_rel, dst_rel)?;
if from != to && entry_is_dir(from) && is_within_or_eq(&to, from) {
return Err(FsError::Invalid(
"cannot move a folder into itself".to_string(),
));
}
Ok(to)
}
/// Conflict rules shared by move and copy:
/// - target is a directory → always conflict (no silent merge)
/// - target is a file → conflict unless overwriting a file with a file
fn check_move_conflict(to: &Path, from: &Path, overwrite: bool) -> Result<(), FsError> {
if to.exists() {
let to_dir = to.is_dir();
let from_dir = from.is_dir();
if entry_exists(to) {
let to_dir = entry_is_dir(to);
let from_dir = entry_is_dir(from);
if to_dir || from_dir || !overwrite {
return Err(FsError::Conflict);
}
@@ -636,7 +762,7 @@ fn copy_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
copy_recursive(&e.path(), &dst.join(e.file_name()))?;
}
} else {
std::fs::copy(src, dst).map_err(|e| io_err(e, dst))?;
copy_file(src, dst)?;
}
set_mtime(dst, meta.modified().ok());
Ok(())
Aserver/tests/api_dav.rs
@@ -0,0 +1,1300 @@
//! 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 base64::Engine as _;
use common::*;
use serde_json::json;
fn basic(name: &str, password: &str) -> String {
let raw = base64::engine::general_purpose::STANDARD.encode(format!("{name}:{password}"));
format!("Basic {raw}")
}
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=\"filebrowser-ng\"")
);
}
// 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("<D:multistatus"), "{body}");
assert!(body.contains(&format!("/dav/{seg}/")), "{body}");
let r = dav(&env, "PROPFIND", &format!("/dav/{seg}/"), Some(&auth), b"").await;
assert_eq!(r.status, StatusCode::MULTI_STATUS);
let body = r.text();
for name in ["docs", "src", "notes.md", "blob.bin"] {
assert!(body.contains(name), "{name} missing from {body}");
}
// Sizes come from the filesystem, not a guess.
assert!(body.contains("<D:getcontentlength>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_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 r = admin
.post_json(
"/api/shares",
&json!({
"root_id": 1,
"path": path,
"writable": writable,
"password": password,
}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
let j = r.json();
(
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_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());
}
// ---------------------------------------------------------------------------
// Locking
// ---------------------------------------------------------------------------
const LOCK_BODY: &[u8] = br#"<?xml version="1.0" encoding="utf-8"?>
<D:lockinfo xmlns:D="DAV:">
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner><D:href>client-one</D:href></D:owner>
</D:lockinfo>"#;
/// Take an exclusive lock and return its token.
async fn lock(env: &Env, path: &str, auth: &str) -> (Resp, Option<String>) {
let r = dav_with(
env,
"LOCK",
path,
Some(auth),
&[("timeout", "Second-300")],
LOCK_BODY,
)
.await;
// The token arrives in `Lock-Token: <urn:uuid:…>`; 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("<D:timeout>Second-600</D:timeout>"), "{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("<D:activelock>"), "{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("<D:getcontentlength>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"), "<script>alert(1)</script>").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("<img src=x onerror=alert(1)>.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("<img src=x"));
}
/// The token is read off the *raw* URL path, because `DavPath` keeps the raw
/// path too and `strip_prefix` byte-compares against it. Taking axum's decoded
/// wildcard instead would split a valid token out of `<token>%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());
}
Mserver/tests/api_files.rs
@@ -1057,3 +1057,179 @@ async fn download_revalidates_with_last_modified() {
assert_eq!(r.status, StatusCode::NOT_MODIFIED);
assert!(r.body.is_empty());
}
// ---------------------------------------------------------------------------
// Symlinks: an operation on a name acts on the entry, not on what it points at
// ---------------------------------------------------------------------------
/// Create `link` inside the root, pointing at `target`.
fn symlink(env: &Env, target: &std::path::Path, link: &str) {
std::os::unix::fs::symlink(target, env.file(link)).unwrap();
}
#[tokio::test]
async fn deleting_a_symlink_removes_the_link_not_its_target() {
let env = Env::new().await;
let admin = env.admin().await;
symlink(&env, &env.file("notes.md"), "alias.md");
let r = admin.delete("/api/files/1/alias.md").await;
assert_eq!(r.status, StatusCode::OK, "{}", 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_can_be_deleted() {
let env = Env::new().await;
let admin = env.admin().await;
symlink(&env, &env.file("gone.txt"), "dangling.md");
// Resolving strictly reports "not found", which would leave the link
// undeletable through the API.
let r = admin.delete("/api/files/1/dangling.md").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(env.file("dangling.md").symlink_metadata().is_err());
}
#[tokio::test]
async fn renaming_a_symlink_renames_the_link() {
let env = Env::new().await;
let admin = env.admin().await;
symlink(&env, &env.file("docs/a.txt"), "alias.txt");
let r = admin
.post_json(
"/api/files/1/alias.txt",
&json!({ "op": "rename", "new_name": "renamed.txt" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
// The link moved. Following it would have renamed the target, and into
// the target's own directory at that.
assert!(
env.file("renamed.txt")
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
assert!(env.file("docs/a.txt").exists());
assert!(!env.file("docs/renamed.txt").exists());
}
#[tokio::test]
async fn moving_a_symlink_moves_the_link() {
let env = Env::new().await;
let admin = env.admin().await;
symlink(&env, &env.file("notes.md"), "alias.md");
let r = admin
.post_json(
"/api/files/1/alias.md",
&json!({ "op": "move", "dst_root_id": 1, "dst": "docs" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert!(
env.file("docs/alias.md")
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
assert!(env.file("notes.md").exists(), "the move followed the link");
}
#[tokio::test]
async fn copying_onto_a_symlink_replaces_it() {
let env = Env::new().await;
let admin = env.admin().await;
// A link inside the root aimed outside it. `std::fs::copy` follows a
// destination symlink, so without unlinking it first the write lands
// outside the root with every path check passing.
let outside = env.root.path().parent().unwrap().join("outside.txt");
std::fs::write(&outside, "SECRET").unwrap();
std::fs::create_dir_all(env.file("dest")).unwrap();
std::os::unix::fs::symlink(&outside, env.file("dest/notes.md")).unwrap();
let r = admin
.post_json(
"/api/files/1/notes.md",
&json!({ "op": "copy", "dst_root_id": 1, "dst": "dest", "overwrite": true }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", 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("dest/notes.md")).unwrap(),
"# notes"
);
assert!(
!env.file("dest/notes.md")
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
}
#[tokio::test]
async fn copying_a_symlink_copies_what_it_points_at() {
let env = Env::new().await;
let admin = env.admin().await;
symlink(&env, &env.file("notes.md"), "alias.md");
std::fs::create_dir_all(env.file("dest")).unwrap();
// The source is followed on purpose: a copy wants the bytes, like `cp`.
let r = admin
.post_json(
"/api/files/1/alias.md",
&json!({ "op": "copy", "dst_root_id": 1, "dst": "dest" }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(
std::fs::read_to_string(env.file("dest/alias.md")).unwrap(),
"# notes"
);
}
#[tokio::test]
async fn a_symlink_out_of_the_root_still_cannot_be_read_or_written() {
let env = Env::new().await;
let admin = env.admin().await;
let outside = env.root.path().parent().unwrap().join("outside.txt");
std::fs::write(&outside, "SECRET").unwrap();
std::os::unix::fs::symlink(&outside, env.file("escape.txt")).unwrap();
// Reads and content writes do follow a link, so containment rests on
// `ensure_within` rejecting one that leaves the root.
let r = admin.get("/api/files/1/escape.txt?action=content").await;
assert!(r.status.is_client_error(), "{}", r.status);
assert_ne!(r.text(), "SECRET");
let r = admin
.put_content("/api/files/1/escape.txt?action=content", b"payload", None)
.await;
assert!(r.status.is_client_error(), "{}", r.status);
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
// Deleting the link is fine: that touches only the entry inside the root.
let r = admin.delete("/api/files/1/escape.txt").await;
assert_eq!(r.status, StatusCode::OK, "{}", r.text());
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "SECRET");
}
Mweb/src/i18n.rs
@@ -197,6 +197,7 @@ i18n_keys! {
CLOSE_ESC = "close_esc" => "Close (Esc)",
COPIED = "copied" => "Copied",
COPY = "copy" => "Copy",
COPY_DAV_LINK = "copy_dav_link" => "Copy WebDAV link",
COPY_HERE = "copy_here" => "Copy here",
COPY_LINK = "copy_link" => "Copy link",
COPY_MANUAL = "copy_manual" => "Copy the link manually",
@@ -500,6 +501,7 @@ const DE: &[(&str, &str)] = &[
("close_esc", "Schließen (Esc)"),
("copied", "Kopiert"),
("copy", "Kopieren"),
("copy_dav_link", "WebDAV-Link kopieren"),
("copy_here", "Hierher kopieren"),
("copy_link", "Link kopieren"),
("copy_manual", "Link manuell kopieren"),
@@ -992,6 +994,7 @@ const FR: &[(&str, &str)] = &[
("close_esc", "Fermer (Échap)"),
("copied", "Copié"),
("copy", "Copier"),
("copy_dav_link", "Copier le lien WebDAV"),
("copy_here", "Copier ici"),
("copy_link", "Copier le lien"),
("copy_manual", "Copiez le lien manuellement"),
Mweb/src/router.rs
@@ -158,6 +158,18 @@ pub fn navigate(loc: &Location) {
}
}
/// Build the WebDAV mount URL for a share token.
///
/// Origin-relative, not page-relative: the mount is a server route, so it does
/// not live under whatever path the app itself was loaded from. Only folder
/// shares have one; a file share has no collection to mount.
pub fn dav_share_url(token: &str) -> String {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
format!("{origin}{}/{token}", api_types::DAV_SHARE)
}
/// Build the share link for a token, relative to the current page origin.
pub fn share_url(token: &str) -> String {
let base = web_sys::window()
Mweb/src/util.rs
@@ -58,6 +58,17 @@ pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
});
}
/// Select the whole value of the `<input>` behind an event, so one click
/// leaves the link ready to copy by hand.
pub fn select_input(ev: &web_sys::Event) {
if let Some(i) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
i.select();
}
}
/// The value of a `<select>` behind an event, when the target is one.
pub fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
Mweb/src/views/browser.rs
@@ -1117,7 +1117,24 @@ fn menu_items(
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
];
// Search is a signed-in feature; a share visitor has none.
// Sharing and search are signed-in features; a share visitor has
// neither, and `SessionUser` refuses a share token outright.
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
i18n::t(i18n::k::SHARE),
action_share(
None,
root_id,
loc,
root_name.clone(),
allow_writable_shares,
is_rw,
set_dialog,
owner.clone(),
),
));
}
if let Some(rid) = root_id
&& loc_now.share_token.is_none()
{
@@ -1213,9 +1230,10 @@ fn menu_items(
IconName::Share,
i18n::t(i18n::k::SHARE),
action_share(
e,
Some(e),
root_id,
loc,
root_name.clone(),
allow_writable_shares,
is_rw,
set_dialog,
@@ -2000,25 +2018,42 @@ fn action_open_tab(
}
/// Open the share dialog for an item (milestone 6).
///
/// `entry` is the item to share, or `None` for the folder currently open.
/// The folder case sends an empty path, which the server reads as the root of
/// the location. At the top of a root that shares the root itself.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn action_share(
entry: &Entry,
entry: Option<&Entry>,
root_id: Option<i64>,
loc: ReadSignal<Location>,
root_name: String,
allow_writable: bool,
root_writable: bool,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
let entry_name = entry.map(|e| e.name.clone());
owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let full = join_path(&loc.path, &name);
let (name, path) = match &entry_name {
Some(n) => (n.clone(), join_path(&loc.path, n)),
// The last path segment names the folder; at the top of a
// root there is none, so the root's own name stands in.
None => (
loc.path
.last()
.cloned()
.unwrap_or_else(|| root_name.clone()),
loc.path.join("/"),
),
};
set_dialog.set(Some(Dialog::Share {
name: name.clone(),
name,
root_id,
path: full,
path,
allow_writable,
root_writable,
}));