feat(drive): fix webdav back-compat

add env variable `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`
    which is by default:
    `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`

    so `/webdav/` -> points to user's personal drive (**backward compatibilit**y)
    `/web/dav/@drive/{uuid|drive name}/` points to the respective drive

    if admins want directly `/webdav/` pointing to list of drives they need to:
    `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`

    + ensure lock is per user (RFC 4918 §9.11)

    fix: #554
This commit is contained in:
Edouard Vanbelle
2026-07-06 20:45:21 +02:00
parent 3d74bed326
commit 7e34045ff8
16 changed files with 1576 additions and 232 deletions
+500 -188
View File
@@ -222,45 +222,170 @@ async fn handle_webdav_methods(
handle_webdav_dispatch(state, req, path).await
}
/// If `path` doesn't already start with the user's home folder name, prepend
/// the home folder path so downstream services can find the resource in the DB.
/// Returns `None` when the path already includes the prefix or resolution fails.
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -> Option<String> {
let folder_service = &state.applications.folder_service;
let home_folders = folder_service
.list_folders_with_perms(None, user_id)
.await
.ok()?;
let home = home_folders.first()?;
if path.starts_with(&home.name) {
None // Already prefixed
} else {
Some(format!("{}/{}", home.path, path))
}
/// Native WebDAV URL scheme (drive.md §9):
///
/// The exact wire shape depends on
/// `FeaturesConfig::webdav_drive_listing_prefix` (env
/// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`, default `"@drive"`):
///
/// | Config | URL | Target |
/// |---|---|---|
/// | `"@drive"` | `/webdav/…` | default drive (back-compat) |
/// | `"@drive"` | `/webdav/@drive/` | drive listing |
/// | `"@drive"` | `/webdav/@drive/<sel>/…` | explicit drive |
/// | `""` | `/webdav/` | drive listing |
/// | `""` | `/webdav/<sel>/…` | explicit drive |
/// | `"drives"` | `/webdav/…` | default drive |
/// | `"drives"` | `/webdav/drives/<sel>/…` | explicit drive |
///
/// `<sel>` is a drive UUID **or** the drive's display name (matched
/// against `storage.folders.name` of the drive root). Only drives the
/// caller has Read on via `role_grants` resolve.
///
/// Legacy tolerance for the default-drive branch: bookmarks that
/// already contain the drive-root name as their first segment
/// (`/webdav/Personal/foo` under a Personal-default user) are passed
/// through instead of double-prepended.
enum WebdavTarget {
/// Render the synthetic drive-listing pseudo-root. Only PROPFIND
/// treats this as a real target; other verbs 405.
ListDrives,
/// Descend into a concrete drive.
Scope(DriveScope),
}
/// Native WebDAV protocol entry: resolve the caller's default drive
/// once per handler so every downstream path-based lookup
/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`)
/// can pass the same `drive_id` scope.
///
/// Post-D0 `storage.{folders,files}.path` repeats across drives — the
/// scope is mandatory. Native WebDAV today lives in a single-drive
/// surface (one default drive per user), so the lookup is unambiguous.
/// Multi-drive support via path segments (`/webdav/drives/<uuid>/…`)
/// is tracked separately and will derive `drive_id` directly from the
/// URL instead of going through `find_default_for_user`.
async fn resolve_drive_id_for_native_webdav(
struct DriveScope {
drive_id: Uuid,
/// Path in `storage.folders.path` format (drive-root name is the
/// leading segment; that prefix is stored per D7).
db_path: String,
}
async fn resolve_webdav_scope(
state: &Arc<AppState>,
user_id: Uuid,
) -> Result<Uuid, AppError> {
state
url_path: &str,
) -> Result<WebdavTarget, AppError> {
let drive_prefix = state
.core
.config
.features
.webdav_drive_listing_prefix
.as_str();
let normalized = url_path.trim_matches('/');
// Mode A: empty prefix. `/webdav/` IS the drive listing.
if drive_prefix.is_empty() {
if normalized.is_empty() {
return Ok(WebdavTarget::ListDrives);
}
let (selector, subpath) = normalized.split_once('/').unwrap_or((normalized, ""));
let drive = lookup_drive_selector(state, user_id, selector).await?;
return Ok(WebdavTarget::Scope(DriveScope {
drive_id: drive.drive.id,
db_path: join_drive_path(&drive.root_folder_name, subpath),
}));
}
// Mode B: non-empty prefix (default `@drive`). Bare `/webdav/` is
// the caller's default drive; drive listing lives at
// `/webdav/<prefix>/`.
let listing_marker = drive_prefix;
if normalized == listing_marker {
return Ok(WebdavTarget::ListDrives);
}
let with_slash = format!("{}/", listing_marker);
if let Some(after_prefix) = normalized.strip_prefix(&with_slash) {
if after_prefix.is_empty() {
return Ok(WebdavTarget::ListDrives);
}
let (selector, subpath) = after_prefix.split_once('/').unwrap_or((after_prefix, ""));
let drive = lookup_drive_selector(state, user_id, selector).await?;
return Ok(WebdavTarget::Scope(DriveScope {
drive_id: drive.drive.id,
db_path: join_drive_path(&drive.root_folder_name, subpath),
}));
}
// Default-drive back-compat.
let default = state
.drive_repo
.find_default_for_user(user_id)
.await
.map(|d| d.drive.id)
.map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)))
.map_err(|e| {
AppError::internal_error(format!("Failed to resolve default drive: {:?}", e))
})?;
let root_name = default.root_folder_name.as_str();
let db_path = if normalized.is_empty() {
root_name.to_string()
} else if normalized == root_name || normalized.starts_with(&format!("{}/", root_name)) {
// Pre-refactor bookmark already carried the drive-root prefix.
normalized.to_string()
} else {
join_drive_path(root_name, normalized)
};
Ok(WebdavTarget::Scope(DriveScope {
drive_id: default.drive.id,
db_path,
}))
}
/// Convenience: unwrap the common Scope branch or map ListDrives to a
/// 405-shape error. Used by every write verb (PUT/DELETE/MOVE/COPY/…)
/// that can't sensibly operate on the drive-listing pseudo-root.
async fn resolve_webdav_scope_or_405(
state: &Arc<AppState>,
user_id: Uuid,
url_path: &str,
) -> Result<DriveScope, AppError> {
match resolve_webdav_scope(state, user_id, url_path).await? {
WebdavTarget::Scope(s) => Ok(s),
WebdavTarget::ListDrives => Err(AppError::method_not_allowed(
"Method not supported on the drive-listing pseudo-root",
)),
}
}
fn join_drive_path(root_name: &str, subpath: &str) -> String {
let subpath = subpath.trim_start_matches('/').trim_end_matches('/');
if subpath.is_empty() {
root_name.to_string()
} else {
format!("{}/{}", root_name, subpath)
}
}
/// Resolve `@drive/<selector>`: try the selector as a UUID first, then
/// fall back to matching the drive-root folder's display name. Only
/// drives the caller has Read access to via `role_grants` are
/// considered — an unknown selector and a permission denial return the
/// same `NotFound` to preserve anti-enumeration.
async fn lookup_drive_selector(
state: &Arc<AppState>,
user_id: Uuid,
selector: &str,
) -> Result<crate::domain::repositories::drive_repository::DriveWithRootName, AppError> {
let selector_decoded = percent_decode_str(selector).decode_utf8_lossy();
let uuid_opt = Uuid::parse_str(selector_decoded.as_ref()).ok();
let visible = state
.drive_repo
.list_readable_by(user_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list drives: {:?}", e)))?;
for d in visible {
if let Some(uuid) = uuid_opt
&& d.drive.id == uuid
{
return Ok(d);
}
if d.root_folder_name == selector_decoded.as_ref() {
return Ok(d);
}
}
Err(AppError::not_found(format!(
"Drive '{}' not found",
selector_decoded
)))
}
async fn handle_webdav_dispatch(
@@ -270,21 +395,9 @@ async fn handle_webdav_dispatch(
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
// Translate WebDAV path → DB path by prepending user's home folder
// prefix when the path doesn't already include it.
// Extract user_id before any async call to keep the future Send.
let path = if !path.is_empty() && method.as_str() != "OPTIONS" {
let user_id = req.extensions().get::<Arc<CurrentUser>>().map(|u| u.id);
if let Some(uid) = user_id {
resolve_webdav_path(&state, uid, &path)
.await
.unwrap_or(path)
} else {
path
}
} else {
path
};
// Path is left as the raw URL path (post-`/webdav/`). Every handler
// that touches storage calls `resolve_webdav_scope` to translate the
// URL → (drive_id, db_path).
match method.as_str() {
"OPTIONS" => handle_options(path).await,
@@ -419,46 +532,46 @@ async fn handle_propfind(
};
// ── 5. Determine target resource ─────────────────────────────
if path.is_empty() || path == "/" {
// Root folder
let root_folder = FolderDto {
id: "root".to_string(),
etag: "root".to_string(),
name: "".to_string(),
path: "".to_string(),
parent_id: None,
// Synthetic root folder for PROPFIND on `/`; not an
// actual DB row, so drive_id has no meaningful value.
drive_id: Uuid::nil(),
created_at: Utc::now().timestamp() as u64,
modified_at: Utc::now().timestamp() as u64,
is_root: true,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
// §14 provenance not applicable to the synthetic root.
created_by: None,
updated_by: None,
};
return build_streaming_propfind_response(
root_folder,
None, // folder_id = None → root children
&depth_owned,
&base_href,
propfind_request,
folder_service,
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
)
.await;
}
// `drive_id` is mandatory post-D0 for path-based lookups. Native
// WebDAV resolves it once from the caller's default drive and
// reuses it for the resolver / fallback probes below.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
//
// `resolve_webdav_scope` handles the URL → scope translation using
// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. It can return either a concrete
// drive scope or the synthetic drive-listing pseudo-root. Only
// PROPFIND treats `ListDrives` as a valid target — other verbs use
// `resolve_webdav_scope_or_405` which errors on that branch.
let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? {
WebdavTarget::ListDrives => {
let root_folder = FolderDto {
id: "root".to_string(),
etag: "root".to_string(),
name: "".to_string(),
path: "".to_string(),
parent_id: None,
// Synthetic root — not a real DB row.
drive_id: Uuid::nil(),
created_at: Utc::now().timestamp() as u64,
modified_at: Utc::now().timestamp() as u64,
is_root: true,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
};
return build_streaming_propfind_response(
root_folder,
None, // folder_id = None → root children (drive-root folders)
&depth_owned,
&base_href,
propfind_request,
folder_service,
file_retrieval_service,
user.id,
state.webdav_dead_props.clone(),
)
.await;
}
WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path),
};
// Single-query path resolution: folder OR file in one DB round-trip.
//
@@ -772,6 +885,13 @@ async fn handle_proppatch(
let user = extract_user(&req)?;
// Client-facing path for href construction (without home folder prefix).
let client_path = extract_webdav_path(req.uri());
// Scope the URL → (drive_id, db_path). The synthetic drive-listing
// pseudo-root has no DB row to anchor dead properties on; treat
// it as an empty target and reject the PROPPATCH itself below.
let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? {
WebdavTarget::ListDrives => (Uuid::nil(), String::new()),
WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path),
};
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
// so a lock on the target must release them via `If:`. Captured
@@ -813,7 +933,7 @@ async fn handle_proppatch(
// PROPPATCH itself below so we don't fabricate a target.
(None, true)
} else {
match resolve_or_legacy(&state, &path, user.id).await {
match resolve_or_legacy(&state, &path, drive_id).await {
Some(ResolvedResource::Folder(folder)) => {
let id = Uuid::parse_str(&folder.id).map_err(|e| {
AppError::internal_error(format!("Folder id is not a UUID: {e}"))
@@ -831,6 +951,21 @@ async fn handle_proppatch(
let resource_ref = resource_ref
.ok_or_else(|| AppError::forbidden("PROPPATCH on the WebDAV root is not supported"))?;
// AuthZ: PROPPATCH writes dead properties on the target — that's
// a mutation, requires `Update`. Without this check any caller who
// can Read (e.g. a Viewer-role grant) could persist dead-prop rows
// on someone else's file. Anti-enum-preserving: `require` maps
// denial to `NotFound`, matching the anonymous-not-found response
// above.
let resource = match resource_ref {
ResourceRef::Folder(id) => Resource::Folder(id),
ResourceRef::File(id) => Resource::File(id),
};
state
.authorization
.require(Subject::User(user.id), Permission::Update, resource)
.await?;
// Read request body (XML — bounded to 1 MB)
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
.await
@@ -909,7 +1044,9 @@ async fn handle_get(
// `drive_id` is the path-lookup scope post-D0 (paths repeat across
// drives), derived once from the caller's default drive and reused
// by both the resolver + legacy fallback.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
let drive_id = scope.drive_id;
let path = scope.db_path;
// Resolve file — drive-scoped when PathResolver is available.
// Post-D7 both branches enforce `Read` on the resolved file
@@ -1034,7 +1171,9 @@ async fn handle_head(
// `drive_id` is the path-lookup scope post-D0 — derive once and
// reuse across the resolver + fallback branches below.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
let drive_id = scope.drive_id;
let path = scope.db_path;
// Single-query path resolution (drive-scoped). Both branches
// enforce `Read` on the resolved resource before emitting the
@@ -1165,20 +1304,12 @@ async fn handle_head(
async fn resolve_or_legacy(
state: &Arc<AppState>,
path: &str,
user_id: Uuid,
drive_id: Uuid,
) -> Option<ResolvedResource> {
// Path-lookup scope post-D0 — derive the caller's default drive
// once and reuse across both probes. `find_default_for_user`
// returning Err (e.g. external user, or boot before the lifecycle
// hook fired) means no resolution is possible: return None.
let drive_id = state
.drive_repo
.find_default_for_user(user_id)
.await
.ok()?
.drive
.id;
// `drive_id` is now passed in by the caller (already computed by
// `resolve_webdav_scope`) so the fallback probes stay consistent
// with the primary resolver — cross-drive URLs no longer silently
// fall back to the caller's default drive.
if let Some(resolver) = &state.path_resolver
&& let Ok(r) = resolver.resolve_path_in_drive(path, drive_id).await
{
@@ -1594,7 +1725,9 @@ async fn handle_put(
// `drive_id` is the path-lookup scope post-D0 — resolve once from
// the caller's default drive, reused by the resolver checks below
// and by the atomic-store call further down.
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
let drive_id = scope.drive_id;
let path = scope.db_path;
// ── Existence check ───────────────────────────────────────────────
// Resolves to: File(existing), Folder(wrong), or Err(new file).
@@ -1782,10 +1915,11 @@ async fn handle_put(
.body(Body::empty())
.unwrap())
}
Err(e) => Err(AppError::internal_error(format!(
"Failed to put file: {}",
e
))),
// Propagate DomainError kinds — NotFound (authz denial via
// `require_target_folder_perm`), Conflict (missing parent) etc.
// Wrapping everything as InternalError swallowed 404s from the
// service's own AuthZ, surfacing them to callers as 500.
Err(e) => Err(AppError::from(e)),
}
}
@@ -1807,9 +1941,13 @@ async fn handle_mkcol(
let user = extract_user(&req)?;
let folder_service = &state.applications.folder_service;
if path.is_empty() || path == "/" {
return Err(AppError::conflict("Root folder already exists"));
}
// Bare `/webdav/` handling: routed through `resolve_webdav_scope_or_405`
// below. In the empty-drive-path config that resolves to the
// drive-listing pseudo-root (405 method-not-allowed); in the
// default `@drive` config it resolves to the default drive's root
// folder (which already exists — the existence probe at
// `exists_in_drive` further down returns 405 per RFC 4918 §9.3.1).
// Both configs end at 405 without a special-case.
// Extract content-type before consuming the body.
let req_content_type = req
@@ -1848,7 +1986,9 @@ async fn handle_mkcol(
// This handler only creates a single collection (the last path segment).
// It does NOT auto-create intermediate ancestors ("mkdir -p" semantics
// violate the RFC and were causing the test failures).
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
let drive_id = scope.drive_id;
let path = scope.db_path;
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
@@ -1977,6 +2117,22 @@ async fn handle_delete(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
// Refuse DELETE on the pseudo-root before any scope work — bare
// `/webdav/` (empty-config drive listing OR classic-config default
// drive root) can't be deleted from the WebDAV surface.
if path.is_empty() || path == "/" {
return Err(AppError::forbidden("Cannot delete root folder"));
}
// Scope resolution BEFORE the lock guard so `enforce_native_lock`
// keys on the same DB path that `handle_lock` used when it
// registered the lock. Doing it in the reverse order (as before
// the drive-scope refactor) silently defeated every LOCK because
// the lock-store key mismatch made every DELETE look unlocked.
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
let drive_id = scope.drive_id;
let path = scope.db_path;
// Active-lock guard (RFC 4918 §9.10.4).
let if_header_owned = req
.headers()
@@ -1997,17 +2153,12 @@ async fn handle_delete(
let file_management_service = &state.applications.file_management_service;
let folder_service = &state.applications.folder_service;
// Check if path is empty (root folder)
if path.is_empty() || path == "/" {
return Err(AppError::forbidden("Cannot delete root folder"));
}
// Resolve via optimized resolver, falling back to the legacy
// double-query lookup (the one GET uses). Necessary because the
// optimized resolver and the read repositories disagree on path
// shape for some files; see `resolve_or_legacy` docs.
let _ = file_retrieval_service; // present for legacy fallback if needed elsewhere
match resolve_or_legacy(&state, &path, user.id).await {
match resolve_or_legacy(&state, &path, drive_id).await {
Some(ResolvedResource::Folder(folder)) => {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
@@ -2062,17 +2213,6 @@ async fn handle_move(
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move
// removes the source resource, which counts as modifying it.
if let Some(resp) = enforce_native_lock(
&state.webdav_lock_store,
if_header_owned.as_deref(),
&source_path,
None,
) {
return Ok(resp);
}
// Get destination from Destination header
let destination = req
.headers()
@@ -2101,22 +2241,41 @@ async fn handle_move(
// SECURITY: reject path-traversal in destination
reject_path_traversal(&destination_path)?;
// Normalize destination through the SAME path-prefixing that
// `resolve_webdav_path` applied to `source_path` during dispatch.
// Without this, comparing source_parent_path (already prefixed with
// the user's home folder name) against dest_parent_path (raw from
// the URL, no prefix) always reports "different parent" — even for a
// pure rename at the same level — and breaks the move/rename branch
// selection below.
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
.await
.unwrap_or(destination_path);
// Resolve BOTH source and destination scope. Cross-drive MOVE is
// permitted: the underlying service methods
// (`move_folder_with_perms` / `move_file_with_perms`) support it
// natively — they enforce the D5 `forbid_cross_drive_move` policy
// per drive and emit a D6 `resource.moved_between_drives` audit
// line when the move crosses a boundary. Downstream probes that
// walk `storage.{folders,files}.path` need the RIGHT drive scope
// for each side; we thread `src_drive_id` for source probes and
// `dst_drive_id` for destination probes.
let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?;
let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?;
let src_drive_id = src_scope.drive_id;
let dst_drive_id = dst_scope.drive_id;
let source_path = src_scope.db_path;
let path = source_path.clone();
let destination_path = dst_scope.db_path;
// RFC 4918 §9.9.3: MOVE to self MUST return 403 Forbidden.
if destination_path == source_path {
if destination_path == path {
return Err(AppError::forbidden("Cannot MOVE a resource to itself"));
}
// Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move
// removes the source resource, which counts as modifying it. The
// guard runs AFTER scope resolution so its lookup keys on the DB
// path — same key `handle_lock` used when it registered the lock.
if let Some(resp) = enforce_native_lock(
&state.webdav_lock_store,
if_header_owned.as_deref(),
&source_path,
None,
) {
return Ok(resp);
}
// Destination lock guard: MOVE also creates/replaces a resource at
// the destination. If that path is locked, the same If: header must
// satisfy it.
@@ -2133,21 +2292,19 @@ async fn handle_move(
let file_management_service = &state.applications.file_management_service;
let folder_service = &state.applications.folder_service;
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
// Probe destination existence for Overwrite semantics and 201 vs 204.
let dest_existed = if let Some(resolver) = &state.path_resolver {
resolver
.exists_in_drive(&destination_path, drive_id)
.exists_in_drive(&destination_path, dst_drive_id)
.await
.unwrap_or(false)
} else {
folder_service
.get_folder_by_path(&destination_path, drive_id)
.get_folder_by_path(&destination_path, dst_drive_id)
.await
.is_ok()
|| file_retrieval_service
.get_file_by_path(&destination_path, drive_id)
.get_file_by_path(&destination_path, dst_drive_id)
.await
.is_ok()
};
@@ -2161,7 +2318,7 @@ async fn handle_move(
// RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the
// destination before moving. Without this the rename/move fails
// on a unique-index conflict (same name in same parent).
match resolve_or_legacy(&state, &destination_path, user.id).await {
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
Some(ResolvedResource::Folder(f)) => {
folder_service
.delete_folder_with_perms(&f.id, user.id)
@@ -2189,7 +2346,7 @@ async fn handle_move(
}
let _ = file_retrieval_service;
let resolved = resolve_or_legacy(&state, &source_path, user.id)
let resolved = resolve_or_legacy(&state, &source_path, src_drive_id)
.await
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
@@ -2213,7 +2370,7 @@ async fn handle_move(
None
} else {
match folder_service
.get_folder_by_path(dest_parent_path, drive_id)
.get_folder_by_path(dest_parent_path, dst_drive_id)
.await
{
Ok(parent) => {
@@ -2262,13 +2419,19 @@ async fn handle_move(
}
}
ResolvedResource::File(file) => {
if source_parent_path != dest_parent_path {
// A cross-drive move always changes the parent folder id even
// if the RELATIVE path within each drive looks the same, so
// we key the "same-parent rename" fast-path off drive id
// agreement as well.
let is_same_parent =
src_drive_id == dst_drive_id && source_parent_path == dest_parent_path;
if !is_same_parent {
// RFC 4918 §9.9.5: missing destination parent → 409 Conflict.
let target_parent_id = if dest_parent_path.is_empty() {
None
} else {
let parent = folder_service
.get_folder_by_path(dest_parent_path, drive_id)
.get_folder_by_path(dest_parent_path, dst_drive_id)
.await
.map_err(|_| {
AppError::conflict(format!(
@@ -2382,12 +2545,20 @@ async fn handle_copy(
// SECURITY: reject path-traversal in destination
reject_path_traversal(&destination_path)?;
// Normalize through the same path-prefixing the dispatcher applied
// to source_path. See the long comment in handle_move for why this
// matters — same root-cause class of asymmetric-path bugs.
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
.await
.unwrap_or(destination_path);
// Resolve BOTH source and destination scope. Cross-drive COPY is
// permitted: `copy_file_with_perms` / `copy_folder_tree_with_perms`
// take a target folder id and don't care which drive it lives in;
// the D5 `forbid_cross_drive_move` policy applies to MOVE only,
// never to COPY (copying is non-destructive on the source side).
// Downstream probes need the right drive per side, so we thread
// `src_drive_id` for source probes and `dst_drive_id` for
// destination probes.
let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?;
let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?;
let src_drive_id = src_scope.drive_id;
let dst_drive_id = dst_scope.drive_id;
let source_path = src_scope.db_path;
let destination_path = dst_scope.db_path;
// RFC 4918 §9.8.5: COPY to self MUST return 403 Forbidden.
if destination_path == source_path {
@@ -2416,21 +2587,23 @@ async fn handle_copy(
let folder_service = &state.applications.folder_service;
let file_management_service = &state.applications.file_management_service;
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
// Scope already resolved above; keep `path` alias for downstream code
// that still reads `path` under its original name.
let _path = source_path.clone();
// Probe destination existence for Overwrite semantics and 201 vs 204.
let dest_existed = if let Some(resolver) = &state.path_resolver {
resolver
.exists_in_drive(&destination_path, drive_id)
.exists_in_drive(&destination_path, dst_drive_id)
.await
.unwrap_or(false)
} else {
folder_service
.get_folder_by_path(&destination_path, drive_id)
.get_folder_by_path(&destination_path, dst_drive_id)
.await
.is_ok()
|| file_retrieval_service
.get_file_by_path(&destination_path, drive_id)
.get_file_by_path(&destination_path, dst_drive_id)
.await
.is_ok()
};
@@ -2444,7 +2617,7 @@ async fn handle_copy(
// RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a
// DELETE on the destination before the copy. Without this the copy
// service returns a unique-index conflict (500).
match resolve_or_legacy(&state, &destination_path, user.id).await {
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
Some(ResolvedResource::Folder(f)) => {
folder_service
.delete_folder_with_perms(&f.id, user.id)
@@ -2472,7 +2645,7 @@ async fn handle_copy(
}
let _ = file_retrieval_service;
let resolved = resolve_or_legacy(&state, &source_path, user.id)
let resolved = resolve_or_legacy(&state, &source_path, src_drive_id)
.await
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
@@ -2490,7 +2663,7 @@ async fn handle_copy(
None
} else {
match folder_service
.get_folder_by_path(dest_parent_path, drive_id)
.get_folder_by_path(dest_parent_path, dst_drive_id)
.await
{
Ok(parent) => {
@@ -2590,25 +2763,101 @@ async fn handle_lock(
) -> Result<Response<Body>, AppError> {
let user = extract_user(&req)?;
// Determine collection-vs-file for href shape. Root + known
// folders → collection; everything else (existing files,
// lock-null on a non-existent path) → file. RFC 4918 §9.10.1
// allows LOCK on a non-existent resource (the "lock-null
// resource" pattern used by Office save flows) — that arm
// falls through to the file href shape, matching the
// request-line shape clients send.
let is_collection = if path.is_empty() || path == "/" {
true
// Scope resolution BEFORE the collection probe so `path` becomes
// the drive-scoped DB path everywhere downstream — critically the
// `lock_store.acquire(&path, …)` call must use the SAME key that
// `enforce_native_lock` will look up from the write verbs
// (PUT/DELETE/MOVE/COPY/PROPPATCH), all of which pass the DB path.
// Locking the URL path here and looking up the DB path in PUT
// would silently defeat the lock — that's the regression this
// shape prevents.
let (drive_id, path) = if path.is_empty() || path == "/" {
(Uuid::nil(), path)
} else {
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
state
.applications
.folder_service
.get_folder_by_path(&path, drive_id)
.await
.is_ok()
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
(scope.drive_id, scope.db_path)
};
// Determine collection-vs-file for href shape AND resolve the
// target for AuthZ. Root + known folders → collection; existing
// files → file; missing path → lock-null (RFC 4918 §7.3 /
// §9.10.1, used by Office save flows). AuthZ per case:
// * Existing folder / file → `Update` on the resource.
// * Lock-null (target doesn't exist yet) → `Create` on the
// parent folder (the lock reserves the URL for a future PUT
// that would need `Create` anyway; deny here so a Viewer
// can't create a lock-null placeholder on someone else's
// namespace).
// Denial routes through `NotFound` (anti-enum), matching the
// rest of the WebDAV surface.
let (is_collection, lockable_resource) = if path.is_empty() {
(true, None)
} else if let Ok(folder) = state
.applications
.folder_service
.get_folder_by_path(&path, drive_id)
.await
{
let uuid = Uuid::parse_str(&folder.id)
.map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Update,
Resource::Folder(uuid),
)
.await?;
(true, Some(Resource::Folder(uuid)))
} else if let Ok(file) = state
.applications
.file_retrieval_service
.get_file_by_path(&path, drive_id)
.await
{
let uuid = Uuid::parse_str(&file.id)
.map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Update,
Resource::File(uuid),
)
.await?;
(false, Some(Resource::File(uuid)))
} else {
// Lock-null: authorise on the parent folder. The last `/` in
// `path` splits parent from name; empty parent means the drive
// root (which itself was already resolved above — the caller
// must have Read on it to have gotten this far via
// `resolve_webdav_scope`).
let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or("");
if !parent_path.is_empty() {
let parent = state
.applications
.folder_service
.get_folder_by_path(parent_path, drive_id)
.await
.map_err(|_| AppError::conflict("Parent folder not found for lock-null"))?;
let parent_uuid = Uuid::parse_str(&parent.id).map_err(|e| {
AppError::internal_error(format!("Parent folder id is not a UUID: {e}"))
})?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Create,
Resource::Folder(parent_uuid),
)
.await?;
}
// No resource to authorise directly — the lock reserves the URL,
// downstream PUT will re-authorise via its own Create/Update.
(false, None)
};
let _ = lockable_resource;
// Get the headers that we need
let depth = req
.headers()
@@ -2690,13 +2939,17 @@ async fn handle_lock(
type_,
};
// Try to acquire the lock (conflict detection via moka store)
let entry = lock_store.acquire(&path, lock_info).map_err(|existing| {
AppError::locked(format!(
"Resource already locked by token {}",
existing.info.token
))
})?;
// Try to acquire the lock (conflict detection via moka store).
// `caller_user_id` is stamped on the entry so `handle_unlock`
// can enforce RFC 4918 §9.11's owner-only rule.
let entry = lock_store
.acquire(&path, lock_info, Some(user.id))
.map_err(|existing| {
AppError::locked(format!(
"Resource already locked by token {}",
existing.info.token
))
})?;
// Generate response — collection vs file href chosen above.
let href = if is_collection {
@@ -2735,9 +2988,9 @@ async fn handle_lock(
async fn handle_unlock(
state: Arc<AppState>,
req: Request<Body>,
_path: String,
path: String,
) -> Result<Response<Body>, AppError> {
let _user = extract_user(&req)?;
let user = extract_user(&req)?;
// Get lock token from Lock-Token header
let lock_token = req
@@ -2753,6 +3006,65 @@ async fn handle_unlock(
.trim_end_matches('>')
.to_string();
// RFC 4918 §9.11 owner-only check. `LockEntry.caller_user_id`
// was stamped by `handle_lock` at acquire time. When the lock
// exists AND we know the acquirer, only that user can UNLOCK.
// Denial routes through the standard authz `NotFound` anti-enum
// — a caller who neither holds the lock nor has any perm on the
// resource shouldn't learn whether the lock exists.
//
// Approximations preserved:
// * Lock entries seeded by tests (`caller_user_id = None`) fall
// through to the Update-based check below — they were never
// bound to a real user.
// * If the token isn't in the store at all (expired, never
// existed) we skip the owner check and let the `release`
// call below return the RFC-standard 409.
let lock_entry = state.webdav_lock_store.get_by_token(&token);
if let Some(entry) = &lock_entry
&& let Some(owner_id) = entry.caller_user_id
&& owner_id != user.id
{
tracing::info!(
target: "audit",
event = "webdav.unlock_denied",
reason = "not_lock_owner",
caller_id = %user.id,
lock_owner_id = %owner_id,
token = %token,
"👮🏻‍♂️ UNLOCK refused: caller does not own the lock",
);
return Err(AppError::not_found(format!(
"Lock token not found or already expired: {}",
token
)));
}
// Defence-in-depth for the test-seeded / legacy `caller_user_id
// = None` case: require `Update` on the target resource so a
// Read-only grantee still can't unlock. Uses the URL path (the
// lock's target) to resolve the resource. Missing target → skip
// (lock-null unlock is legitimate).
if let Some(entry) = &lock_entry
&& entry.caller_user_id.is_none()
&& !path.is_empty()
&& path != "/"
{
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
let drive_id = scope.drive_id;
let db_path = scope.db_path;
if let Some(resource) = match resolve_or_legacy(&state, &db_path, drive_id).await {
Some(ResolvedResource::Folder(f)) => Uuid::parse_str(&f.id).ok().map(Resource::Folder),
Some(ResolvedResource::File(f)) => Uuid::parse_str(&f.id).ok().map(Resource::File),
None => None,
} {
state
.authorization
.require(Subject::User(user.id), Permission::Update, resource)
.await?;
}
}
// Remove the lock from the store
if !state.webdav_lock_store.release(&token) {
// RFC 4918 §9.11.1: If the lock does not exist, return 409 Conflict