diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index 776b96e0..53c77285 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -15,6 +15,8 @@ use crate::application::dtos::display_helpers::{ use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::entities::folder::Folder; /// Result of resolving a WebDAV path — either a folder or a file. #[derive(Debug, Clone)] @@ -33,14 +35,20 @@ impl PathResolverService { Self { pool } } - /// Resolve `path` to a folder or file **owned by `user_id`**. + /// Resolve `path` to a folder or file **within the given drive**. /// - /// Adds `AND fo.user_id = $4` / `AND fi.user_id = $4` so that one - /// user can never resolve another user's resources. - pub async fn resolve_path_for_user( + /// Filters on `fo.drive_id = $4` / `fi.drive_id = $4`. Callers + /// pre-resolve which drive they're operating in — native WebDAV + /// derives it from the caller's default drive + /// (`resolve_drive_id_for_native_webdav`); NC WebDAV takes it from + /// the URL-selected chroot (`chroot.drive_id`). Shared by both + /// surfaces so the single-query UNION ALL optimisation lands + /// consistently and no path lookup keys on the doomed + /// `storage.{files,folders}.user_id` column. + pub async fn resolve_path_in_drive( &self, path: &str, - user_id: Uuid, + drive_id: Uuid, ) -> Result { let path = path.trim_start_matches('/').trim_end_matches('/'); if path.is_empty() { @@ -55,6 +63,13 @@ impl PathResolverService { String::new() }; + // Widened SELECT: also fetches `blob_hash` (for file ETag) and + // `tree_modified_at` (for folder ETag). Both share the same + // canonical formulas as the rest of the codebase — see + // [`File::compute_etag`] and [`Folder::compute_etag`]. Without + // these two extra columns the resolver used to emit empty + // ETag strings, and NC's `If-Match` round-trips broke + // (see the F6b regression on `test_nc_put_mkcol_blake3.sh`). let row = sqlx::query_as::< _, ( @@ -70,11 +85,14 @@ impl PathResolverService { Option, // size Option, // mime_type Option, // folder_id + Option, // blob_hash (files only) + Option, // tree_modified_at (folders only) ), >( r#" SELECT resource_type, id, name, path, parent_id, user_id, drive_id, - created_at, modified_at, size, mime_type, folder_id + created_at, modified_at, size, mime_type, folder_id, + blob_hash, tree_modified_at FROM ( SELECT 'folder'::text AS resource_type, fo.id::text, @@ -87,10 +105,12 @@ impl PathResolverService { EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at, NULL::bigint AS size, NULL::text AS mime_type, - NULL::text AS folder_id + NULL::text AS folder_id, + NULL::text AS blob_hash, + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint AS tree_modified_at FROM storage.folders fo WHERE fo.path = $1 AND NOT fo.is_trashed - AND fo.user_id = $4 + AND fo.drive_id = $4 UNION ALL @@ -109,7 +129,9 @@ impl PathResolverService { EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at, fi.size, fi.mime_type, - fi.folder_id::text + fi.folder_id::text, + fi.blob_hash, + NULL::bigint AS tree_modified_at FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.name = $2 @@ -118,7 +140,7 @@ impl PathResolverService { OR fo.path = $3 ) AND NOT fi.is_trashed - AND fi.user_id = $4 + AND fi.drive_id = $4 ) sub LIMIT 1 "#, @@ -126,10 +148,10 @@ impl PathResolverService { .bind(path) // $1 .bind(filename) // $2 .bind(&folder_path) // $3 - .bind(user_id) // $4 + .bind(drive_id) // $4 .fetch_optional(self.pool.as_ref()) .await - .map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))? + .map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_in_drive: {e}")))? .ok_or_else(|| DomainError::not_found("Resource", path))?; let ( @@ -145,38 +167,41 @@ impl PathResolverService { size, mime_type, folder_id, + blob_hash, + tree_modified_at, ) = row; match resource_type.as_str() { - "folder" => Ok(ResolvedResource::Folder(FolderDto { - etag: id.clone(), - id, - name: name.clone(), - path: res_path, - parent_id, - owner_id: uid, - drive_id, - created_at: created_at as u64, - modified_at: modified_at as u64, - is_root: false, - icon_class: Arc::from("fas fa-folder"), - icon_special_class: Arc::from("folder-icon"), - category: Arc::from("Folder"), - // §14 provenance not selected by this resolver path — - // it's used for existence/type discrimination, not - // detailed DTO emission. Callers that need provenance - // reload through the repo. - created_by: None, - updated_by: None, - })), + "folder" => { + let tree_mod = tree_modified_at.unwrap_or(modified_at) as u64; + Ok(ResolvedResource::Folder(FolderDto { + etag: Folder::compute_etag(&id, tree_mod), + id, + name: name.clone(), + path: res_path, + parent_id, + owner_id: uid, + drive_id, + created_at: created_at as u64, + modified_at: modified_at as u64, + is_root: false, + icon_class: Arc::from("fas fa-folder"), + icon_special_class: Arc::from("folder-icon"), + category: Arc::from("Folder"), + // §14 provenance not selected by this resolver path — + // it's used for existence/type discrimination, not + // detailed DTO emission. Callers that need provenance + // reload through the repo. + created_by: None, + updated_by: None, + })) + } _ => { let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string()); let sz = size.unwrap_or(0) as u64; - // `content_hash`/`etag` are empty here: this resolver - // path doesn't select `blob_hash` from SQL — callers - // are doing existence/type discrimination, not ETag - // emission. If a caller ever needs an ETag from this - // codepath, widen the SELECT and populate properly. + let hash = blob_hash.unwrap_or_default(); + let modified_at_u = modified_at as u64; + let etag = File::compute_etag(&hash, modified_at_u); Ok(ResolvedResource::File(FileDto { id, name: name.clone(), @@ -185,15 +210,15 @@ impl PathResolverService { mime_type: Arc::from(&*mime), folder_id, created_at: created_at as u64, - modified_at: modified_at as u64, + modified_at: modified_at_u, icon_class: Arc::from(icon_class_for(&name, &mime)), icon_special_class: Arc::from(icon_special_class_for(&name, &mime)), category: Arc::from(category_for(&name, &mime)), size_formatted: format_file_size(sz), owner_id: uid, sort_date: None, - content_hash: String::new(), - etag: String::new(), + content_hash: hash, + etag, // §14 provenance not selected by this resolver path created_by: None, updated_by: None, @@ -203,7 +228,10 @@ impl PathResolverService { } /// Returns `true` if the resource at `path` belongs to `user_id`. - pub async fn exists_for_user(&self, path: &str, user_id: Uuid) -> Result { + /// Check whether `path` resolves to a folder or file within the + /// given drive. Companion to `resolve_path_in_drive` — same scope + /// filter, existence-only projection. + pub async fn exists_in_drive(&self, path: &str, drive_id: Uuid) -> Result { let path = path.trim_start_matches('/').trim_end_matches('/'); if path.is_empty() { return Ok(false); @@ -221,7 +249,7 @@ impl PathResolverService { r#" SELECT EXISTS( SELECT 1 FROM storage.folders - WHERE path = $1 AND NOT is_trashed AND user_id = $4 + WHERE path = $1 AND NOT is_trashed AND drive_id = $4 ) OR EXISTS( SELECT 1 FROM storage.files fi @@ -229,18 +257,18 @@ impl PathResolverService { WHERE fi.name = $2 AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3) AND NOT fi.is_trashed - AND fi.user_id = $4 + AND fi.drive_id = $4 ) "#, ) .bind(path) .bind(filename) .bind(&folder_path) - .bind(user_id) + .bind(drive_id) .fetch_one(self.pool.as_ref()) .await .map_err(|e| { - DomainError::internal_error("PathResolver", format!("exists_for_user: {e}")) + DomainError::internal_error("PathResolver", format!("exists_in_drive: {e}")) })?; Ok(exists) diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 01b3821b..b5bfe696 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -456,10 +456,32 @@ async fn handle_propfind( .await; } - // Single-query path resolution: folder OR file in one DB round-trip + // `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?; + + // Single-query path resolution: folder OR file in one DB round-trip. + // + // Post-D7 the resolver is drive-scoped (not owner-scoped), so we + // explicitly `authz.require(Read, …)` on the returned resource + // before rendering the multistatus. The streaming children of a + // Folder branch are separately per-item authorised inside + // `build_streaming_propfind_response` via `_with_perms` service + // methods. if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path_for_user(&path, user.id).await { + match resolver.resolve_path_in_drive(&path, drive_id).await { Ok(ResolvedResource::Folder(folder)) => { + let folder_uuid = Uuid::parse_str(&folder.id) + .map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; let folder_id = folder.id.clone(); return build_streaming_propfind_response( folder, @@ -475,6 +497,16 @@ async fn handle_propfind( .await; } Ok(ResolvedResource::File(file)) => { + let file_uuid = Uuid::parse_str(&file.id) + .map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; let dead_props = file_dead_props(&state, &file).await; let file_href = webdav_href(&client_path); let mut buf = Vec::with_capacity(1024); @@ -503,9 +535,6 @@ async fn handle_propfind( } } else { // Fallback: legacy double-query path when PathResolver is unavailable. - // `drive_id` is mandatory post-D0 for path-based lookups — derive - // the caller's default drive once and reuse it for both probes. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await { let folder_uuid = Uuid::parse_str(&folder.id) .map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?; @@ -875,10 +904,38 @@ async fn handle_get( return Err(AppError::bad_request("Cannot GET a directory")); } - // Resolve file — user-scoped when PathResolver is available + // `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?; + + // Resolve file — drive-scoped when PathResolver is available. + // Post-D7 both branches enforce `Read` on the resolved file + // explicitly. The download stream call below also passes + // `caller_id`, so `get_file_stream_with_perms` re-verifies as + // defence-in-depth. + // + // (Fix, 2026-07-02: the legacy fallback branch previously used + // `Permission::Update`, a stale mapping from the retired + // `assert_owner` helper. That would have locked Viewers out of + // downloads once shared drives were exposed via WebDAV. Both + // branches now share the correct `Read` permission — see the + // post-D7 second AuthZ audit memo.) let file = if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path_for_user(&path, user.id).await { - Ok(ResolvedResource::File(f)) => f, + match resolver.resolve_path_in_drive(&path, drive_id).await { + Ok(ResolvedResource::File(f)) => { + let file_uuid = Uuid::parse_str(&f.id) + .map_err(|_| AppError::not_found(format!("File not found: {}", path)))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + f + } Ok(ResolvedResource::Folder(_)) => { return Err(AppError::bad_request("Cannot GET a directory")); } @@ -887,11 +944,7 @@ async fn handle_get( } } } else { - // Legacy fallback — fetch + AuthZ check. `drive_id` is the - // path-lookup scope post-D0 (`storage.files.path` repeats across - // drives), derived once from the caller's default drive. PUT - // overwrite = Update on the existing file. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // Legacy fallback — fetch + AuthZ check. let f = file_retrieval_service .get_file_by_path(&path, drive_id) .await @@ -902,7 +955,7 @@ async fn handle_get( .authorization .require( Subject::User(user.id), - Permission::Update, + Permission::Read, Resource::File(file_uuid), ) .await?; @@ -977,10 +1030,26 @@ async fn handle_head( .unwrap()); } - // Single-query path resolution (user-scoped) + // `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?; + + // Single-query path resolution (drive-scoped). Both branches + // enforce `Read` on the resolved resource before emitting the + // metadata response — same permission as the legacy fallback below. if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path_for_user(&path, user.id).await { + match resolver.resolve_path_in_drive(&path, drive_id).await { Ok(ResolvedResource::Folder(folder)) => { + let folder_uuid = Uuid::parse_str(&folder.id) + .map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; return Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "httpd/unix-directory") @@ -990,6 +1059,16 @@ async fn handle_head( .unwrap()); } Ok(ResolvedResource::File(file)) => { + let file_uuid = Uuid::parse_str(&file.id) + .map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; return Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, &*file.mime_type) @@ -1009,9 +1088,6 @@ async fn handle_head( } // Fallback: legacy double-query path (with ownership check). - // `drive_id` is the path-lookup scope post-D0 — derive once and - // reuse for both the folder and file probes. - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await { let folder_uuid = Uuid::parse_str(&folder.id) .map_err(|_| AppError::not_found(format!("Resource not found: {}", path)))?; @@ -1089,16 +1165,10 @@ async fn resolve_or_legacy( path: &str, user_id: Uuid, ) -> Option { - if let Some(resolver) = &state.path_resolver - && let Ok(r) = resolver.resolve_path_for_user(path, user_id).await - { - return Some(r); - } - // Path-lookup scope post-D0 — derive the caller's default drive - // for both legacy probes. `find_default_for_user` returning Err - // (e.g. external user, or boot before the lifecycle hook fired) - // means no fallback resolution is possible: return None. + // 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) @@ -1107,17 +1177,18 @@ async fn resolve_or_legacy( .drive .id; - let user_id_str = user_id.to_string(); - let folder_service = &state.applications.folder_service; - if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await - && folder.owner_id.as_deref() == Some(&user_id_str) + if let Some(resolver) = &state.path_resolver + && let Ok(r) = resolver.resolve_path_in_drive(path, drive_id).await { + return Some(r); + } + + let folder_service = &state.applications.folder_service; + if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await { return Some(ResolvedResource::Folder(folder)); } let file_retrieval = &state.applications.file_retrieval_service; - if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await - && file.owner_id.as_deref() == Some(&user_id_str) - { + if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await { return Some(ResolvedResource::File(file)); } None @@ -1350,14 +1421,35 @@ async fn handle_put( return Ok(resp); } - // ── Ownership / existence check ─────────────────────────────────── + // `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?; + + // ── Existence check ─────────────────────────────────────────────── // Resolves to: File(existing), Folder(wrong), or Err(new file). // Sets `file_existed` for 201 vs 204 and `current_etag` for If-Match. + // + // Post-D7 the resolver is drive-scoped, not owner-scoped, so we + // explicitly `authz.require(Read, …)` on every returned resource + // before consuming it. The actual overwrite is authorised as + // `Update` inside `update_file_streaming`; this Read check is the + // defence-in-depth existence-proof (see project_webdav_authz_second_audit). let mut file_existed = false; let mut current_etag: Option = None; if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path_for_user(&path, user.id).await { + match resolver.resolve_path_in_drive(&path, drive_id).await { Ok(ResolvedResource::File(f)) => { + let file_uuid = Uuid::parse_str(&f.id) + .map_err(|_| AppError::not_found(format!("File not found: {}", path)))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; file_existed = true; current_etag = Some(f.etag.clone()); } @@ -1369,12 +1461,36 @@ async fn handle_put( // parent MUST produce 409 Conflict, not 404. let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or(""); if !parent_path.is_empty() { - resolver - .resolve_path_for_user(parent_path, user.id) + let parent = resolver + .resolve_path_in_drive(parent_path, drive_id) .await .map_err(|_| { AppError::conflict(format!("Parent folder not found: {}", parent_path)) })?; + if let ResolvedResource::Folder(folder) = parent { + let folder_uuid = Uuid::parse_str(&folder.id).map_err(|_| { + AppError::conflict(format!("Parent folder not found: {}", parent_path)) + })?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await + .map_err(|_| { + AppError::conflict(format!( + "Parent folder not found: {}", + parent_path + )) + })?; + } else { + return Err(AppError::conflict(format!( + "Parent path is a file, not a collection: {}", + parent_path + ))); + } } } } @@ -1449,8 +1565,11 @@ async fn handle_put( } // ── Atomic store ────────────────────────────────────────────────── + // `drive_id` was resolved above (existence-check block) and is + // reused here — the same drive that scoped the resolver scopes the + // write. `update_file_streaming` enforces `Permission::Update` + // internally via its `_with_perms` shape. let content_type = ingested.content_type.clone(); - let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let result = file_upload_service .update_file_streaming( &path, @@ -1552,9 +1671,12 @@ async fn handle_mkcol( } // Check whether the target itself already exists (file or folder → 405). + // `exists_in_drive` is an existence-only probe (returns a bool), so no + // per-resource authz is applicable here — the create call below is + // authorised via `Permission::Create` on the parent. if let Some(resolver) = &state.path_resolver { if resolver - .exists_for_user(&path, user.id) + .exists_in_drive(&path, drive_id) .await .unwrap_or(false) { @@ -1587,10 +1709,29 @@ async fn handle_mkcol( None } else { let parent_path = parent_segments.join("/"); - // Parent must be a folder, not a file. + // Parent must be a folder, not a file. Post-D7 the resolver is + // drive-scoped so we explicitly `authz.require(Read, Folder)` on the + // returned parent — the actual create then runs under + // `Permission::Create` on the same folder inside `create_folder_with_perms`. if let Some(resolver) = &state.path_resolver { - match resolver.resolve_path_for_user(&parent_path, user.id).await { - Ok(ResolvedResource::Folder(f)) => Some(f.id), + match resolver.resolve_path_in_drive(&parent_path, drive_id).await { + Ok(ResolvedResource::Folder(f)) => { + let folder_uuid = Uuid::parse_str(&f.id).map_err(|_| { + AppError::conflict(format!("Parent folder not found: {}", parent_path)) + })?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await + .map_err(|_| { + AppError::conflict(format!("Parent folder not found: {}", parent_path)) + })?; + Some(f.id) + } Ok(ResolvedResource::File(_)) => { return Err(AppError::conflict( "Parent path is a file, not a collection", @@ -1807,7 +1948,7 @@ async fn handle_move( // Probe destination existence for Overwrite semantics and 201 vs 204. let dest_existed = if let Some(resolver) = &state.path_resolver { resolver - .exists_for_user(&destination_path, user.id) + .exists_in_drive(&destination_path, drive_id) .await .unwrap_or(false) } else { @@ -2089,7 +2230,7 @@ async fn handle_copy( // Probe destination existence for Overwrite semantics and 201 vs 204. let dest_existed = if let Some(resolver) = &state.path_resolver { resolver - .exists_for_user(&destination_path, user.id) + .exists_in_drive(&destination_path, drive_id) .await .unwrap_or(false) } else { diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 410f0ed1..ab67f6dd 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -15,6 +15,7 @@ use uuid::Uuid; use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter}; use crate::application::dtos::pagination::PaginationRequestDto; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, @@ -23,6 +24,8 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; +use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE; use crate::interfaces::errors::AppError; use crate::interfaces::range_requests::{not_modified_response, range_response}; @@ -230,75 +233,122 @@ async fn handle_propfind( let internal_path = nc_to_internal_path(chroot, subpath)?; - let folder_service = &state.applications.folder_service; - let file_service = &state.applications.file_retrieval_service; - - // Try to resolve as folder first. - let folder_result = folder_service - .get_folder_by_path(&internal_path, chroot.drive_id) - .await; - - if let Ok(folder) = folder_result { - // It's a folder — stream the multistatus: children are fetched in - // pages and serialized chunk by chunk, so memory stays O(batch) - // regardless of how many entries the folder holds. - // - // Multi-drive POC: the hrefs in the response must echo the - // wire form (`{user}~{drive}`) the client requested, so we - // pass `url_user` (not `user.username`) as the streaming - // function's username arg. Refining the owner-id usages - // back to the canonical username is deferred to the - // NcSession commit. - return Ok(build_nc_streaming_propfind( - state.clone(), - folder, - depth, - user.id, - url_user.to_string(), - subpath.to_string(), - )); - } - - // Not a folder — try as a file. - let file_result = file_service - .get_file_by_path(&internal_path, chroot.drive_id) - .await; - if let Ok(file) = file_result { - // Batch-check favorites for this single file. - let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() { - let items: Vec<(&str, &str)> = vec![(&file.id, "file")]; - fav_svc - .batch_check_favorites(user.id, &items) - .await - .unwrap_or_default() - } else { - HashSet::new() - }; - - let nc = state.nextcloud.as_ref(); - let file_id_svc = nc.map(|n| &n.file_ids); - - let mut buf = Vec::new(); - write_nc_file_multistatus( - &mut buf, - &file, - url_user, - &user.username, - subpath, - file_id_svc, - &favorite_ids, - ) + // Single-query path resolution (drive-scoped) — same shared + // resolver as native `/webdav/…`. Post-D7 the resolver is not + // owner-scoped, so we `authz.require(Read, …)` on the returned + // resource explicitly before emitting the multistatus. + let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id) .await - .map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?; + .ok_or_else(|| AppError::not_found("Resource not found"))?; - return Ok(Response::builder() - .status(StatusCode::MULTI_STATUS) - .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(buf)) - .unwrap()); + match resolved { + ResolvedResource::Folder(folder) => { + let folder_uuid = Uuid::parse_str(&folder.id) + .map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; + + // It's a folder — stream the multistatus: children are fetched in + // pages and serialized chunk by chunk, so memory stays O(batch) + // regardless of how many entries the folder holds. + // + // Multi-drive POC: the hrefs in the response must echo the + // wire form (`{user}~{drive}`) the client requested, so we + // pass `url_user` (not `user.username`) as the streaming + // function's username arg. Refining the owner-id usages + // back to the canonical username is deferred to the + // NcSession commit. + Ok(build_nc_streaming_propfind( + state.clone(), + folder, + depth, + user.id, + url_user.to_string(), + subpath.to_string(), + )) + } + ResolvedResource::File(file) => { + let file_uuid = + Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + + // Batch-check favorites for this single file. + let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() { + let items: Vec<(&str, &str)> = vec![(&file.id, "file")]; + fav_svc + .batch_check_favorites(user.id, &items) + .await + .unwrap_or_default() + } else { + HashSet::new() + }; + + let nc = state.nextcloud.as_ref(); + let file_id_svc = nc.map(|n| &n.file_ids); + + let mut buf = Vec::new(); + write_nc_file_multistatus( + &mut buf, + &file, + url_user, + &user.username, + subpath, + file_id_svc, + &favorite_ids, + ) + .await + .map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?; + + Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(buf)) + .unwrap()) + } } +} - Err(AppError::not_found("Resource not found")) +/// NC-surface path resolution: try the single-query resolver, fall back +/// to the double-query `get_*_by_path` pair when the resolver isn't +/// configured. Same shape and drive-scope as the native surface — +/// callers `authz.require(…)` on the returned resource. +async fn nc_resolve_or_fallback( + state: &Arc, + internal_path: &str, + drive_id: Uuid, +) -> Option { + if let Some(resolver) = &state.path_resolver + && let Ok(r) = resolver + .resolve_path_in_drive(internal_path, drive_id) + .await + { + return Some(r); + } + let folder_service = &state.applications.folder_service; + if let Ok(folder) = folder_service + .get_folder_by_path(internal_path, drive_id) + .await + { + return Some(ResolvedResource::Folder(folder)); + } + let file_service = &state.applications.file_retrieval_service; + if let Ok(file) = file_service.get_file_by_path(internal_path, drive_id).await { + return Some(ResolvedResource::File(file)); + } + None } // ──────────────────── GET ──────────────────── @@ -319,27 +369,50 @@ async fn handle_get( .unwrap()); } + let user = &session.user; let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; - let folder_service = &state.applications.folder_service; - // Check if path is a folder first (NC clients use GET as existence check) - if folder_service - .get_folder_by_path(&internal_path, chroot.drive_id) + // Single-query path resolution. NC clients use GET on a folder as + // an existence probe (returns 200 empty); file GETs serve content. + // Post-D7 the resolver is drive-scoped, so both branches + // `authz.require(Read, …)` before responding. + let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id) .await - .is_ok() - { - return Ok(Response::builder() - .status(StatusCode::OK) - .header("DAV", "1, 3") - .body(Body::empty()) - .unwrap()); - } + .ok_or_else(|| AppError::not_found("File not found"))?; - let file = file_service - .get_file_by_path(&internal_path, chroot.drive_id) - .await - .map_err(|_| AppError::not_found("File not found"))?; + let file = match resolved { + ResolvedResource::Folder(folder) => { + let folder_uuid = + Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 3") + .body(Body::empty()) + .unwrap()); + } + ResolvedResource::File(f) => { + let file_uuid = + Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + f + } + }; // ETag comes from `FileDto::etag` (populated from `File::etag()` // in the `From` impl) — single source of truth, so GET, @@ -406,27 +479,47 @@ async fn handle_head( .unwrap()); } + let user = &session.user; let internal_path = nc_to_internal_path(chroot, subpath)?; - let file_service = &state.applications.file_retrieval_service; - let folder_service = &state.applications.folder_service; - // Check if path is a folder (NC clients use HEAD as existence check) - if folder_service - .get_folder_by_path(&internal_path, chroot.drive_id) + // Single-query path resolution. Both branches `authz.require(Read, …)` + // on the returned resource before responding. + let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id) .await - .is_ok() - { - return Ok(Response::builder() - .status(StatusCode::OK) - .header("DAV", "1, 3") - .body(Body::empty()) - .unwrap()); - } + .ok_or_else(|| AppError::not_found("File not found"))?; - let file = file_service - .get_file_by_path(&internal_path, chroot.drive_id) - .await - .map_err(|_| AppError::not_found("File not found"))?; + let file = match resolved { + ResolvedResource::Folder(folder) => { + let folder_uuid = + Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("DAV", "1, 3") + .body(Body::empty()) + .unwrap()); + } + ResolvedResource::File(f) => { + let file_uuid = + Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + f + } + }; let modified_at = chrono::DateTime::::from_timestamp(timestamp_to_i64(file.modified_at), 0) @@ -486,20 +579,41 @@ async fn handle_proppatch( // the prior behaviour. A PROPPATCH that *does* try to set // favorite on a missing resource still returns NotFound. let internal_path = nc_to_internal_path(chroot, subpath)?; - let file_service = &state.applications.file_retrieval_service; - let folder_service = &state.applications.folder_service; - let resource = if let Ok(file) = file_service - .get_file_by_path(&internal_path, chroot.drive_id) - .await - { - Some((file.id, "file")) - } else if let Ok(folder) = folder_service - .get_folder_by_path(&internal_path, chroot.drive_id) - .await - { - Some((folder.id, "folder")) - } else { - None + + // Single-query path resolution — PROPPATCH may target either a + // folder or a file. Post-D7 the resolver is drive-scoped, so we + // `authz.require(Read, …)` on the returned resource before + // reading its type. The favorite mutation below itself doesn't + // require additional authz (favorites are per-user; the caller can + // favourite any resource they can see). + let resource = match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await { + Some(ResolvedResource::File(f)) => { + let file_uuid = + Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + Some((f.id, "file")) + } + Some(ResolvedResource::Folder(folder)) => { + let folder_uuid = Uuid::parse_str(&folder.id) + .map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; + Some((folder.id, "folder")) + } + None => None, }; let is_collection = matches!(resource, Some((_, "folder"))); @@ -877,74 +991,79 @@ async fn handle_delete( let chroot = session.require_chroot()?; let internal_path = nc_to_internal_path(chroot, subpath)?; let folder_service = &state.applications.folder_service; - let file_service = &state.applications.file_retrieval_service; - // Prefer soft-delete (move to trash) when trash service is available. - // This is what Nextcloud clients expect — items appear in the trashbin. - if let Some(trash_svc) = state.trash_service.as_ref() { - if let Ok(folder) = folder_service - .get_folder_by_path(&internal_path, chroot.drive_id) - .await - { - trash_svc - .move_to_trash(&folder.id, "folder", user.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to trash folder: {}", e)))?; - return Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) - .unwrap()); - } - if let Ok(file) = file_service - .get_file_by_path(&internal_path, chroot.drive_id) - .await - { - trash_svc - .move_to_trash(&file.id, "file", user.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to trash file: {}", e)))?; - return Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) - .unwrap()); - } - return Err(AppError::not_found("Resource not found")); - } - - // Fallback: hard delete when trash service is not available. - let file_mgmt = &state.applications.file_management_service; - - if let Ok(folder) = folder_service - .get_folder_by_path(&internal_path, chroot.drive_id) + // Single-query path resolution. Post-D7 the resolver is drive-scoped, + // so we `authz.require(Read, …)` on the returned resource before + // dispatching. The actual delete is authorised as `Permission::Delete` + // inside the downstream service (`trash_svc.move_to_trash` / + // `delete_folder_with_perms` / `delete_file_with_perms` all take + // `caller_id`). + let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id) .await - { - folder_service - .delete_folder_with_perms(&folder.id, user.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?; + .ok_or_else(|| AppError::not_found("Resource not found"))?; - return Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) - .unwrap()); + match resolved { + ResolvedResource::Folder(folder) => { + let folder_uuid = Uuid::parse_str(&folder.id) + .map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; + if let Some(trash_svc) = state.trash_service.as_ref() { + trash_svc + .move_to_trash(&folder.id, "folder", user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to trash folder: {}", e)) + })?; + } else { + folder_service + .delete_folder_with_perms(&folder.id, user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to delete folder: {}", e)) + })?; + } + } + ResolvedResource::File(file) => { + let file_uuid = + Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + if let Some(trash_svc) = state.trash_service.as_ref() { + trash_svc + .move_to_trash(&file.id, "file", user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to trash file: {}", e)) + })?; + } else { + let file_mgmt = &state.applications.file_management_service; + file_mgmt + .delete_file_with_perms(&file.id, user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to delete file: {}", e)) + })?; + } + } } - if let Ok(file) = file_service - .get_file_by_path(&internal_path, chroot.drive_id) - .await - { - file_mgmt - .delete_file_with_perms(&file.id, user.id) - .await - .map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?; - - return Ok(Response::builder() - .status(StatusCode::NO_CONTENT) - .body(Body::empty()) - .unwrap()); - } - - Err(AppError::not_found("Resource not found")) + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap()) } // ──────────────────── MOVE ──────────────────── @@ -992,21 +1111,18 @@ async fn handle_move( let file_mgmt = &state.applications.file_management_service; // ── Destination-collision precondition (RFC 4918 §9.9.4) ────────── - // Resolved once up-front so the file/folder branches below don't - // each have to repeat the check. `dest_existed_before` becomes the - // 204-vs-201 selector at response time. + // Single-query probe via the shared resolver — the destination is + // either a file, a folder, or absent. `dest_existed_before` + // becomes the 204-vs-201 selector at response time. Post-D7 the + // resolver is drive-scoped; on the overwrite path we + // `authz.require(Read, …)` explicitly and the downstream delete + // enforces `Permission::Delete`. let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?; - let dest_existing_file = file_service - .get_file_by_path(&dest_internal_precheck, chroot.drive_id) - .await - .ok(); - let dest_existing_folder = folder_service - .get_folder_by_path(&dest_internal_precheck, chroot.drive_id) - .await - .ok(); - let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some(); + let dest_existing = + nc_resolve_or_fallback(&state, &dest_internal_precheck, chroot.drive_id).await; + let dest_existed_before = dest_existing.is_some(); - if dest_existed_before { + if let Some(existing) = dest_existing { if overwrite_forbidden { return Ok(Response::builder() .status(StatusCode::PRECONDITION_FAILED) @@ -1017,23 +1133,51 @@ async fn handle_move( // then proceed with the move. Trashing is fine: per RFC the source // resource appears at the destination URI; what happens to the // overwritten one is up to the server. - if let Some(existing_file) = &dest_existing_file { - file_mgmt - .delete_and_cleanup_with_perms(&existing_file.id, user.id) - .await - .map_err(|e| { - AppError::internal_error(format!("Failed to overwrite destination file: {}", e)) + match existing { + ResolvedResource::File(existing_file) => { + let file_uuid = Uuid::parse_str(&existing_file.id).map_err(|_| { + AppError::internal_error("Failed to overwrite destination file") })?; - } else if let Some(existing_folder) = &dest_existing_folder { - folder_service - .delete_folder_with_perms(&existing_folder.id, user.id) - .await - .map_err(|e| { - AppError::internal_error(format!( - "Failed to overwrite destination folder: {}", - e - )) + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + file_mgmt + .delete_and_cleanup_with_perms(&existing_file.id, user.id) + .await + .map_err(|e| { + AppError::internal_error(format!( + "Failed to overwrite destination file: {}", + e + )) + })?; + } + ResolvedResource::Folder(existing_folder) => { + let folder_uuid = Uuid::parse_str(&existing_folder.id).map_err(|_| { + AppError::internal_error("Failed to overwrite destination folder") })?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(folder_uuid), + ) + .await?; + folder_service + .delete_folder_with_perms(&existing_folder.id, user.id) + .await + .map_err(|e| { + AppError::internal_error(format!( + "Failed to overwrite destination folder: {}", + e + )) + })?; + } } }