From 50fb34659e72b4b3c538b14bfa0af109d96f316a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 19 Jun 2026 07:49:33 +0200 Subject: [PATCH] feat(drive): limit NC request by disk --- src/application/dtos/folder_dto.rs | 8 + src/application/ports/file_ports.rs | 15 +- src/application/ports/folder_ports.rs | 20 +- src/application/ports/storage_ports.rs | 39 +++- .../services/file_retrieval_service.rs | 7 +- .../services/file_upload_service.rs | 11 +- src/application/services/folder_service.rs | 8 +- .../services/idor_protection_test.rs | 12 +- src/application/services/share_service.rs | 15 +- src/application/services/trash_service.rs | 4 + .../services/trash_service_test.rs | 10 +- src/common/stubs.rs | 25 ++- src/domain/entities/folder.rs | 40 +++- src/domain/repositories/file_repository.rs | 9 +- src/domain/repositories/folder_repository.rs | 25 ++- .../pg/file_blob_read_repository.rs | 52 ++++- .../repositories/pg/folder_db_repository.rs | 200 ++++++++++++------ .../services/path_resolver_service.rs | 7 +- .../api/handlers/favorites_handler.rs | 5 + src/interfaces/api/handlers/folder_handler.rs | 4 + src/interfaces/api/handlers/recent_handler.rs | 5 + src/interfaces/api/handlers/webdav_handler.rs | 109 ++++++++-- src/interfaces/api/handlers/wopi_handler.rs | 23 +- .../nextcloud/basic_auth_middleware.rs | 6 +- src/interfaces/nextcloud/report_handler.rs | 7 +- src/interfaces/nextcloud/trashbin_handler.rs | 7 +- src/interfaces/nextcloud/uploads_handler.rs | 21 +- src/interfaces/nextcloud/webdav_handler.rs | 76 ++++--- 28 files changed, 591 insertions(+), 179 deletions(-) diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 1cd5ca3a..c712c37f 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -52,6 +52,12 @@ pub struct FolderDto { #[serde(skip_serializing_if = "Option::is_none")] pub owner_id: Option, + /// Drive that owns this folder. The scope axis for path-based + /// lookups across REST / WebDAV / NextCloud / CalDAV / CardDAV. + /// Post-D0 `storage.folders.drive_id` is `NOT NULL`; stub / + /// DTO-reconstructed folders carry `Uuid::nil()`. + pub drive_id: Uuid, + /// Creation timestamp pub created_at: u64, @@ -93,6 +99,7 @@ impl From for FolderDto { path: folder.path_string().to_string(), parent_id: folder.parent_id().map(String::from), owner_id: folder.owner_id().map(|u| u.to_string()), + drive_id: folder.drive_id(), created_at: folder.created_at(), modified_at: folder.modified_at(), is_root, @@ -144,6 +151,7 @@ impl FolderDto { path: "/stub/path".to_string(), parent_id: None, owner_id: None, + drive_id: Uuid::nil(), created_at: 0, modified_at: 0, is_root: true, diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index cdfec8ce..bbb67099 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -56,9 +56,15 @@ pub trait FileUploadUseCase: Send + Sync + 'static { /// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT). /// /// Takes ownership of the blob's reference (released on failure). + /// + /// `drive_id` scopes both the existence probe (`find_file_by_path`) + /// and the parent-folder resolution (`get_parent_folder_id`) — the + /// handler is responsible for deriving it from its protocol context + /// (NC chroot, native default-drive lookup, WOPI default-drive). async fn update_file_streaming( &self, path: &str, + drive_id: Uuid, blob: StoredBlob, content_type: &str, modified_at: Option, @@ -104,8 +110,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { caller_id: Uuid, ) -> Result; - /// Gets a file by its path (for WebDAV) - async fn get_file_by_path(&self, path: &str) -> Result; + /// Gets a file by its path (for WebDAV), scoped to a drive. + /// + /// Post-D0, `storage.files.path` is unique only within a single + /// drive. The `drive_id` filter scopes the lookup to a specific + /// drive (caller derives it from its protocol context: NC chroot, + /// native default-drive lookup, WOPI default-drive lookup). + async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result; /// Lists files in a folder async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs index 76b2c6f0..0f7b42f5 100644 --- a/src/application/ports/folder_ports.rs +++ b/src/application/ports/folder_ports.rs @@ -38,14 +38,18 @@ pub trait FolderUseCase: Send + Sync + 'static { /// Gets a folder by its path within the caller's tree. /// - /// Scoped by `user_id` because `storage.folders.path` is unique - /// only within a single user's drive after D0 — multiple users - /// share names like `"Personal"` for their default-drive root - /// folder (docs/plan/drive.md §10). Pre-D0 the wrapper name - /// embedded the username and made the path globally unique; - /// post-D0 the caller_id filter is required. - async fn get_folder_by_path(&self, path: &str, user_id: Uuid) - -> Result; + /// Scoped by `drive_id` because `storage.folders.path` is unique + /// only within a single drive after D0 — multiple drives (whether + /// owned by the same user or different users) share names like + /// `"Personal"` for their root folder (docs/plan/drive.md §10). + /// Pre-D0 the wrapper name embedded the username; post-D0 the + /// caller derives a `drive_id` from its protocol context (NC + /// chroot, native default-drive lookup, WOPI default-drive). + async fn get_folder_by_path( + &self, + path: &str, + drive_id: Uuid, + ) -> Result; /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 41fb559c..52c7dbaf 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -82,11 +82,25 @@ pub trait FileReadPort: Send + Sync + 'static { /// Gets the logical storage path of a file. async fn get_file_path(&self, id: &str) -> Result; - /// Gets the parent folder ID from a path (WebDAV). - async fn get_parent_folder_id(&self, path: &str) -> Result; + /// Gets the parent folder ID from a path (WebDAV), scoped to a drive. + /// + /// Post-D0, `storage.folders.path` is unique only within a single + /// drive. The `drive_id` filter scopes the lookup to a specific + /// drive (caller derives it from its protocol context: NC chroot, + /// native default-drive lookup, WOPI default-drive lookup). + async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid) + -> Result; - /// Gets a folder ID by its path. - async fn get_folder_id_by_path(&self, folder_path: &str) -> Result; + /// Gets a folder ID by its path, scoped to a drive. + /// + /// Post-D0 same scoping rule as `get_parent_folder_id` — names like + /// `"Personal"` repeat across drives, so the `drive_id` filter is + /// required to disambiguate. + async fn get_folder_id_by_path( + &self, + folder_path: &str, + drive_id: Uuid, + ) -> Result; /// Gets the content-addressable blob hash for a file (O(1) DB lookup). /// @@ -94,11 +108,22 @@ pub trait FileReadPort: Send + Sync + 'static { /// Used for dedup reference tracking without loading file content. async fn get_blob_hash(&self, file_id: &str) -> Result; - /// Find a file by its logical path (folder_name/.../file_name). + /// Find a file by its logical path (folder_name/.../file_name), + /// scoped to a drive. + /// + /// Post-D0 `storage.files.path` is unique only within a single + /// drive. The `drive_id` filter prevents non-deterministic + /// resolution when the same path exists in multiple drives. /// /// The default implementation falls back to `list_files(None)` + linear - /// scan (O(N)). Repositories should override with a direct SQL query. - async fn find_file_by_path(&self, path: &str) -> Result, DomainError> { + /// scan (O(N)) and ignores the drive filter — only used by stubs. + /// Repositories should override with a direct SQL query that applies + /// the filter. + async fn find_file_by_path( + &self, + path: &str, + _drive_id: Uuid, + ) -> Result, DomainError> { let path = path.trim_start_matches('/').trim_end_matches('/'); let all_files = self.list_files(None).await?; for file in all_files { diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 27ae48e7..afe4d5c1 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -286,13 +286,16 @@ impl FileRetrievalUseCase for FileRetrievalService { } // FIXME no authorisation at all - async fn get_file_by_path(&self, path: &str) -> Result { + async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result { // Direct SQL lookup — O(folder_depth) queries instead of O(total_files) // NOTE: This method does NOT perform any authorization check. Callers // that surface its result to a user-driven request MUST resolve the // file via get_file_owned afterwards, or call authz.require directly. // (Tracked in the audit punch-list under "path-based lookups".) - if let Some(file) = self.file_read.find_file_by_path(path).await? { + // `drive_id` scope axis prevents cross-drive resolution — without + // it, `find_file_by_path` would return a non-deterministic row + // when the same path exists in multiple drives. + if let Some(file) = self.file_read.find_file_by_path(path, drive_id).await? { return Ok(FileDto::from(file)); } diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 824883ec..c84239c7 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -348,13 +348,14 @@ impl FileUploadUseCase for FileUploadService { async fn update_file_streaming( &self, path: &str, + drive_id: Uuid, blob: StoredBlob, content_type: &str, modified_at: Option, ) -> Result { // Try to find the existing file first if let Some(file_read) = &self.file_read - && let Some(file) = file_read.find_file_by_path(path).await? + && let Some(file) = file_read.find_file_by_path(path, drive_id).await? { let file_id = file.id().to_string(); let (new_hash, updated_at) = self @@ -402,9 +403,15 @@ impl FileUploadUseCase for FileUploadService { // get_parent_folder_id expects the full file path — it strips the // last segment (filename) internally to find the parent folder. + // `drive_id` scopes the parent lookup to the same drive as the + // incoming write (post-D0 `storage.folders.path` repeats across + // drives). let parent_id = if path_normalized.contains('/') { if let Some(file_read) = &self.file_read { - file_read.get_parent_folder_id(path_normalized).await.ok() + file_read + .get_parent_folder_id(path_normalized, drive_id) + .await + .ok() } else { None } diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index f00d7583..48c99121 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -85,7 +85,7 @@ impl FolderService { async fn get_folder_by_path( &self, _path: &str, - _user_id: Uuid, + _drive_id: Uuid, ) -> Result { Ok(FolderDto::empty()) } @@ -297,17 +297,17 @@ impl FolderUseCase for FolderService { self.get_folder(id).await } - /// Gets a folder by its path, scoped to the caller's tree. + /// Gets a folder by its path, scoped to a drive. async fn get_folder_by_path( &self, path: &str, - user_id: Uuid, + drive_id: Uuid, ) -> Result { let storage_path = StoragePath::from_string(path); let folder = self .folder_storage - .get_folder_by_path(&storage_path, user_id) + .get_folder_by_path(&storage_path, drive_id) .await .map_err(|e| { DomainError::internal_error( diff --git a/src/application/services/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index 5277a873..385daf48 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -101,7 +101,11 @@ impl FileReadPort for MockFileReadPort { unimplemented!() } - async fn get_parent_folder_id(&self, _path: &str) -> Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: Uuid, + ) -> Result { unimplemented!() } @@ -127,7 +131,11 @@ impl FileReadPort for MockFileReadPort { Ok(0) } - async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + _folder_path: &str, + _drive_id: Uuid, + ) -> Result { unimplemented!() } diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index d2366d6f..6e869394 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -838,11 +838,19 @@ mod tests { unimplemented!() } - async fn get_parent_folder_id(&self, _path: &str) -> Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: uuid::Uuid, + ) -> Result { unimplemented!() } - async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + _folder_path: &str, + _drive_id: uuid::Uuid, + ) -> Result { unimplemented!() } @@ -925,7 +933,7 @@ mod tests { async fn get_folder_by_path( &self, _storage_path: &crate::domain::services::path_service::StoragePath, - _user_id: uuid::Uuid, + _drive_id: uuid::Uuid, ) -> Result { unimplemented!() } @@ -991,6 +999,7 @@ mod tests { async fn folder_exists( &self, _storage_path: &crate::domain::services::path_service::StoragePath, + _drive_id: uuid::Uuid, ) -> Result { unimplemented!() } diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 589f4ae9..a3bc766d 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -821,6 +821,10 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Trash listing — drive_id is informational and the trash + // row doesn't currently SELECT it. Path-based lookups + // never enter this code path. + drive_id: uuid::Uuid::nil(), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index c6582284..d1de1a2f 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -500,13 +500,18 @@ impl FileReadPort for MockFileRepository { unimplemented!() } - async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: Uuid, + ) -> std::result::Result { unimplemented!() } async fn get_folder_id_by_path( &self, _folder_path: &str, + _drive_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -709,7 +714,7 @@ impl FolderRepository for MockFolderRepository { async fn get_folder_by_path( &self, _storage_path: &StoragePath, - _user_id: Uuid, + _drive_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -773,6 +778,7 @@ impl FolderRepository for MockFolderRepository { async fn folder_exists( &self, _storage_path: &StoragePath, + _drive_id: Uuid, ) -> std::result::Result { Ok(false) } diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 1ef4a0fc..c48bc6bd 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -101,11 +101,19 @@ impl FileReadPort for StubFileReadPort { Ok(StoragePath::from_string("/")) } - async fn get_parent_folder_id(&self, _path: &str) -> Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: Uuid, + ) -> Result { Ok("root".to_string()) } - async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + _folder_path: &str, + _drive_id: Uuid, + ) -> Result { Ok("stub-folder-id".to_string()) } @@ -245,7 +253,7 @@ impl FolderRepository for StubFolderStoragePort { async fn get_folder_by_path( &self, _storage_path: &StoragePath, - _user_id: Uuid, + _drive_id: Uuid, ) -> Result { Ok(Folder::default()) } @@ -299,7 +307,11 @@ impl FolderRepository for StubFolderStoragePort { Ok(()) } - async fn folder_exists(&self, _storage_path: &StoragePath) -> Result { + async fn folder_exists( + &self, + _storage_path: &StoragePath, + _drive_id: Uuid, + ) -> Result { Ok(false) } @@ -405,7 +417,7 @@ impl FolderUseCase for StubFolderUseCase { async fn get_folder_by_path( &self, _path: &str, - _user_id: Uuid, + _drive_id: Uuid, ) -> Result { Ok(FolderDto::default()) } @@ -495,6 +507,7 @@ impl FileUploadUseCase for StubFileUploadUseCase { async fn update_file_streaming( &self, _path: &str, + _drive_id: Uuid, _blob: StoredBlob, _content_type: &str, _modified_at: Option, @@ -577,7 +590,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Box::new(empty_stream)) } - async fn get_file_by_path(&self, _path: &str) -> Result { + async fn get_file_by_path(&self, _path: &str, _drive_id: Uuid) -> Result { Err(DomainError::not_found("File", "stub")) } diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 6607b917..3e731291 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -29,6 +29,13 @@ pub struct Folder { /// `None` only for legacy/stub folders; real folders always have an owner. owner_id: Option, + /// Drive that owns this folder. Post-D0 every `storage.folders` row + /// has `drive_id NOT NULL` (M3 migration). Path-based lookups scope + /// by this axis (not by `user_id`, which is dropped in D7). + /// `Uuid::nil()` only for stub/legacy in-memory folders that never + /// touched the DB. + drive_id: Uuid, + /// Creation timestamp created_at: u64, @@ -56,6 +63,7 @@ impl Default for Folder { path_string: "/".to_string(), parent_id: None, owner_id: None, + drive_id: Uuid::nil(), created_at: 0, modified_at: 0, tree_modified_at: 0, @@ -103,6 +111,11 @@ impl Folder { path_string, parent_id, owner_id, + // In-memory constructor: callers that don't supply a + // drive_id are by definition stub/legacy paths (tests, + // pre-D0 fixtures, DTO round-trips). Real DB-backed + // folders flow through `with_timestamps_and_tree`. + drive_id: Uuid::nil(), created_at: now, modified_at: now, tree_modified_at: now, @@ -128,6 +141,7 @@ impl Folder { storage_path, parent_id, None, + Uuid::nil(), created_at, modified_at, modified_at, @@ -154,6 +168,7 @@ impl Folder { storage_path, parent_id, owner_id, + Uuid::nil(), created_at, modified_at, modified_at, @@ -162,7 +177,9 @@ impl Folder { /// Full constructor used by the PG repository when reading rows. /// `tree_modified_at` comes from the trigger-maintained column on - /// `storage.folders` and feeds [`Folder::etag`]. + /// `storage.folders` and feeds [`Folder::etag`]. `drive_id` is the + /// post-D0 `storage.folders.drive_id NOT NULL` column — every + /// path-based lookup scopes by this axis. #[allow(clippy::too_many_arguments)] pub fn with_timestamps_and_tree( id: String, @@ -170,6 +187,7 @@ impl Folder { storage_path: StoragePath, parent_id: Option, owner_id: Option, + drive_id: Uuid, created_at: u64, modified_at: u64, tree_modified_at: u64, @@ -188,6 +206,7 @@ impl Folder { path_string, parent_id, owner_id, + drive_id, created_at, modified_at, tree_modified_at, @@ -227,6 +246,13 @@ impl Folder { self.owner_id } + /// Drive that owns this folder. Path-based lookups scope by + /// this axis (post-D0 invariant: `storage.folders.drive_id` + /// is `NOT NULL`). + pub fn drive_id(&self) -> Uuid { + self.drive_id + } + /// Latest descendant-write timestamp. Statement-level Postgres /// triggers enqueue every file/folder write into /// `storage.tree_etag_dirty`; the background `TreeEtagFlushService` @@ -314,6 +340,11 @@ impl Folder { path_string: path, parent_id, owner_id: None, + // DTO round-trips lose drive_id (FolderDto carries it, + // but the legacy `from_dto` signature predates this + // change). Callers that need real scoping must reload + // through the repository. + drive_id: Uuid::nil(), created_at, modified_at, tree_modified_at: modified_at, @@ -353,6 +384,7 @@ impl Folder { path_string: new_path_string, parent_id: self.parent_id.clone(), owner_id: self.owner_id, + drive_id: self.drive_id, created_at: self.created_at, modified_at: now, // Renaming bumps both self and descendant rollup — @@ -389,6 +421,7 @@ impl Folder { path_string: new_path_string, parent_id, owner_id: self.owner_id, + drive_id: self.drive_id, created_at: self.created_at, modified_at: now, tree_modified_at: now, @@ -478,6 +511,7 @@ mod tests { StoragePath::from_string("/folder"), None, None, + Uuid::nil(), 1_000, 2_000, 5_000, @@ -499,6 +533,7 @@ mod tests { StoragePath::from_string("/a"), None, None, + Uuid::nil(), 0, 0, 42, @@ -510,6 +545,7 @@ mod tests { StoragePath::from_string("/b"), None, None, + Uuid::nil(), 0, 0, 42, @@ -532,6 +568,7 @@ mod tests { StoragePath::from_string("/folder"), None, None, + Uuid::nil(), 1_000, 2_000, 3_000, @@ -543,6 +580,7 @@ mod tests { StoragePath::from_string("/folder"), None, None, + Uuid::nil(), 1_000, 2_000, 4_000, diff --git a/src/domain/repositories/file_repository.rs b/src/domain/repositories/file_repository.rs index d9878f2e..60bee7c0 100644 --- a/src/domain/repositories/file_repository.rs +++ b/src/domain/repositories/file_repository.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use bytes::Bytes; use futures::Stream; +use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::entities::file::File; @@ -49,8 +50,12 @@ pub trait FileReadRepository: Send + Sync + 'static { /// Gets the logical storage path of a file. async fn get_file_path(&self, id: &str) -> Result; - /// Gets the parent folder ID from a path (WebDAV). - async fn get_parent_folder_id(&self, path: &str) -> Result; + /// Gets the parent folder ID from a path (WebDAV), scoped to a drive. + /// + /// Post-D0, `storage.folders.path` is unique only within a single + /// drive — the `drive_id` filter scopes the lookup. + async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid) + -> Result; } // ───────────────────────────────────────────────────── diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 642a83e4..80ebde96 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -28,17 +28,17 @@ pub trait FolderRepository: Send + Sync + 'static { /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result; - /// Gets a folder by its storage path within the caller's tree. + /// Gets a folder by its storage path within a drive's tree. /// - /// Post-D0, `storage.folders.path` is no longer globally unique — - /// multiple users share root-folder names like `"Personal"`. The - /// `user_id` filter scopes the lookup to the caller's own folders - /// (the equivalent of the pre-D0 implicit user-namespacing that - /// came from `My Folder - ` paths). + /// Post-D0, `storage.folders.path` is unique only within a single + /// drive — root-folder names like `"Personal"` repeat across drives. + /// The `drive_id` filter scopes the lookup to a specific drive + /// (caller derives it from its protocol context: NC chroot, native + /// default-drive lookup, WOPI default-drive lookup). async fn get_folder_by_path( &self, storage_path: &StoragePath, - user_id: Uuid, + drive_id: Uuid, ) -> Result; /// Lists folders within a parent folder @@ -87,8 +87,15 @@ pub trait FolderRepository: Send + Sync + 'static { /// Deletes a folder async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; - /// Checks if a folder exists at the given path - async fn folder_exists(&self, storage_path: &StoragePath) -> Result; + /// Checks if a folder exists at the given path within a drive. + /// + /// Post-D0 `storage.folders.path` is unique only within a single + /// drive — the `drive_id` filter scopes the existence check. + async fn folder_exists( + &self, + storage_path: &StoragePath, + drive_id: Uuid, + ) -> Result; /// Gets the path of a folder async fn get_folder_path(&self, id: &str) -> Result; diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 35c31df8..ef7ed650 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -935,7 +935,11 @@ impl FileReadPort for FileBlobReadRepository { Ok(Self::make_file_path(row.1.as_deref(), &row.0)) } - async fn get_parent_folder_id(&self, path: &str) -> Result { + async fn get_parent_folder_id( + &self, + path: &str, + drive_id: Uuid, + ) -> Result { let path = path.trim_start_matches('/').trim_end_matches('/'); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); @@ -955,29 +959,49 @@ impl FileReadPort for FileBlobReadRepository { )); } - self.get_folder_id_by_path(&folder_path).await + self.get_folder_id_by_path(&folder_path, drive_id).await } - async fn get_folder_id_by_path(&self, folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + folder_path: &str, + drive_id: Uuid, + ) -> Result { let folder_path = folder_path.trim_start_matches('/').trim_end_matches('/'); if folder_path.is_empty() { return Err(DomainError::not_found("Folder", "empty path")); } + // Post-D0 `storage.folders.path` repeats across drives — + // filter by `drive_id` to scope the lookup. sqlx::query_scalar::<_, String>( - "SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed", + "SELECT id::text FROM storage.folders \ + WHERE path = $1 AND drive_id = $2 AND NOT is_trashed", ) .bind(folder_path) + .bind(drive_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobRead", format!("folder lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", format!("path: {folder_path}"))) } - /// Direct SQL lookup using materialized folder paths. + /// Direct SQL lookup using materialized folder paths, scoped to a drive. /// O(1) query instead of O(depth) folder walk. - async fn find_file_by_path(&self, path: &str) -> Result, DomainError> { + /// + /// Post-D0 `storage.folders.path` repeats across drives (each drive + /// has its own root with a name like `"Personal"`). Without the + /// `drive_id` filter the lookup would be non-deterministic. The + /// root-level branch filters on `fi.drive_id`; the nested branch + /// filters on the parent folder's `fo.drive_id` (which closes the + /// leak cleanly and matches the path semantics — see Step 2 of + /// the path-lookup refactor). + async fn find_file_by_path( + &self, + path: &str, + drive_id: Uuid, + ) -> Result, DomainError> { let path = path.trim_start_matches('/').trim_end_matches('/'); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); @@ -996,7 +1020,8 @@ impl FileReadPort for FileBlobReadRepository { let folder_path = segments[..segments.len() - 1].join("/"); let row = if folder_path.is_empty() { - // File at root level (no parent folder) + // File at root level (no parent folder) — filter on + // `fi.drive_id` because there's no folder row to join through. sqlx::query_as::< _, ( @@ -1021,14 +1046,19 @@ impl FileReadPort for FileBlobReadRepository { fi.user_id FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id - WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed + WHERE fi.name = $1 AND fi.folder_id IS NULL + AND fi.drive_id = $2 AND NOT fi.is_trashed "#, ) .bind(filename) + .bind(drive_id) .fetch_optional(self.pool.as_ref()) .await } else { - // File inside a folder — look up by folder path + filename + // File inside a folder — look up by folder path + filename, + // filtered by the parent folder's drive_id (path semantics + // are folder-scoped, so this also catches mis-pointed file + // rows during D0/D7's dual-write window). sqlx::query_as::< _, ( @@ -1053,11 +1083,13 @@ impl FileReadPort for FileBlobReadRepository { fi.user_id FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id - WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed + WHERE fo.path = $1 AND fi.name = $2 + AND fo.drive_id = $3 AND NOT fi.is_trashed "#, ) .bind(&folder_path) .bind(filename) + .bind(drive_id) .fetch_optional(self.pool.as_ref()) .await } diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 9f27980a..66b6e731 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -21,11 +21,23 @@ use crate::domain::services::authorization::ResourceKind; use crate::domain::services::path_service::StoragePath; /// Type alias for folder metadata rows from SQL queries. -/// Tuple order: id, name, path, parent_id, user_id, created_at, -/// modified_at, tree_modified_at. The trailing `tree_modified_at` -/// feeds [`Folder::etag`] — every SELECT here must include -/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. -type FolderRow = (String, String, String, Option, Uuid, i64, i64, i64); +/// Tuple order: id, name, path, parent_id, user_id, drive_id, +/// created_at, modified_at, tree_modified_at. The trailing +/// `tree_modified_at` feeds [`Folder::etag`] — every SELECT here +/// must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. +/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based +/// lookups. +type FolderRow = ( + String, + String, + String, + Option, + Uuid, + Uuid, + i64, + i64, + i64, +); /// Type alias for paginated folder rows (includes total_count as /// the last element after `tree_modified_at`). @@ -35,6 +47,7 @@ type FolderRowPaginated = ( String, Option, Uuid, + Uuid, i64, i64, i64, @@ -48,6 +61,7 @@ type FolderRowOptUser = ( String, Option, Option, + Uuid, i64, i64, i64, @@ -92,6 +106,7 @@ impl FolderDbRepository { path: String, parent_id: Option, user_id: Option, + drive_id: Uuid, created_at: i64, modified_at: i64, tree_modified_at: i64, @@ -103,6 +118,7 @@ impl FolderDbRepository { storage_path, parent_id, user_id, + drive_id, created_at as u64, modified_at as u64, tree_modified_at as u64, @@ -210,6 +226,7 @@ impl FolderRepository for FolderDbRepository { row.1, parent_id, Some(user_id), + drive_id, row.2, row.3, row.4, @@ -219,7 +236,7 @@ impl FolderRepository for FolderDbRepository { async fn get_folder(&self, id: &str) -> Result { let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -233,13 +250,23 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + ) } async fn get_folder_by_path( &self, storage_path: &StoragePath, - user_id: Uuid, + drive_id: Uuid, ) -> Result { let path_str = storage_path.to_string(); // Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs" @@ -249,31 +276,41 @@ impl FolderRepository for FolderDbRepository { return Err(DomainError::not_found("Folder", "empty path")); } - // Scoped by user_id: post-D0 the wrapper folder is named - // "Personal" for every user, so `path = 'Personal'` matches - // every user's root folder. Without the user_id filter, this - // returns a non-deterministic row (whichever the planner emits - // first) — which broke owner-short-circuit checks for the - // caller whose folder wasn't returned. See bug-fix on rewind - // commit. + // Scoped by drive_id: post-D0 `storage.folders.path` is unique + // only within a single drive. Root-folder names like + // `"Personal"` repeat across drives, so without the drive_id + // filter the planner returns a non-deterministic row — which + // breaks owner-short-circuit checks and crosses drive + // boundaries (the AuthZ axis that replaces the old per-user + // wrapper scoping post-D0). let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders - WHERE path = $1 AND user_id = $2 AND NOT is_trashed + WHERE path = $1 AND drive_id = $2 AND NOT is_trashed "#, ) .bind(lookup) - .bind(user_id) + .bind(drive_id) .fetch_optional(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", lookup))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + ) } #[allow(clippy::type_complexity)] @@ -281,7 +318,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -296,7 +333,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -311,8 +348,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) }) .collect() } @@ -326,7 +363,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -342,7 +379,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -358,8 +395,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) }) .collect() } @@ -378,7 +415,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -397,7 +434,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -417,15 +454,15 @@ impl FolderRepository for FolderDbRepository { // total_count is identical in every row; 0 when the result set is empty. let total = if include_total { - Some(rows.first().map_or(0, |r| r.8) as usize) + Some(rows.first().map_or(0, |r| r.9) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) }) .collect(); Ok((folders?, total)) @@ -445,7 +482,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -465,7 +502,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, @@ -485,15 +522,15 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; let total = if include_total { - Some(rows.first().map_or(0, |r| r.8) as usize) + Some(rows.first().map_or(0, |r| r.9) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) }) .collect(); Ok((folders?, total)) @@ -512,7 +549,7 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.folders SET name = $1, updated_at = NOW(), updated_by = user_id WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, path, parent_id::text, user_id, + RETURNING id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -533,7 +570,17 @@ impl FolderRepository for FolderDbRepository { })? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + ) } async fn move_folder( @@ -551,7 +598,7 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.folders SET parent_id = $1::uuid, updated_at = NOW(), updated_by = user_id WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, path, parent_id::text, user_id, + RETURNING id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -565,7 +612,17 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + ) } async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { @@ -603,14 +660,22 @@ impl FolderRepository for FolderDbRepository { Ok(()) } - async fn folder_exists(&self, storage_path: &StoragePath) -> Result { + async fn folder_exists( + &self, + storage_path: &StoragePath, + drive_id: Uuid, + ) -> Result { let path_str = storage_path.to_string(); let lookup = path_str.strip_prefix('/').unwrap_or(&path_str); + // Post-D0 `storage.folders.path` repeats across drives — + // filter by `drive_id` to scope the existence check. let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM storage.folders WHERE path = $1 AND NOT is_trashed)", + "SELECT EXISTS(SELECT 1 FROM storage.folders \ + WHERE path = $1 AND drive_id = $2 AND NOT is_trashed)", ) .bind(lookup) + .bind(drive_id) .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?; @@ -836,9 +901,17 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; match row { - Some((id, path, ca, ma, tma)) => { - Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma) - } + Some((id, path, ca, ma, tma)) => Self::row_to_folder( + id, + name.clone(), + path, + None, + Some(user_id), + drive_id, + ca, + ma, + tma, + ), None => { // Already exists — fetch it let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>( @@ -863,6 +936,7 @@ impl FolderRepository for FolderDbRepository { existing.1, None, Some(user_id), + drive_id, existing.2, existing.3, existing.4, @@ -878,7 +952,7 @@ impl FolderRepository for FolderDbRepository { #[allow(clippy::type_complexity)] async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ @@ -896,8 +970,8 @@ impl FolderRepository for FolderDbRepository { })?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) }) .collect() } @@ -941,7 +1015,7 @@ impl FolderRepository for FolderDbRepository { // Recursive, no folder scope → ALL user folders let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ @@ -968,8 +1042,8 @@ impl FolderRepository for FolderDbRepository { return rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) }) .collect(); } @@ -978,7 +1052,7 @@ impl FolderRepository for FolderDbRepository { let sql = if parent_id.is_some() { format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ @@ -997,7 +1071,7 @@ impl FolderRepository for FolderDbRepository { }; format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ @@ -1040,8 +1114,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) }) .collect() } @@ -1066,7 +1140,7 @@ impl FolderRepository for FolderDbRepository { let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ @@ -1096,8 +1170,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma) }) .collect() } @@ -1115,7 +1189,7 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -1141,7 +1215,7 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint @@ -1167,8 +1241,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma) }) .collect() } diff --git a/src/infrastructure/services/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index ea187db5..831066ff 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -64,6 +64,7 @@ impl PathResolverService { String, // path Option, // parent_id Option, // user_id + Uuid, // drive_id i64, // created_at i64, // modified_at Option, // size @@ -72,7 +73,7 @@ impl PathResolverService { ), >( r#" - SELECT resource_type, id, name, path, parent_id, user_id, + SELECT resource_type, id, name, path, parent_id, user_id, drive_id, created_at, modified_at, size, mime_type, folder_id FROM ( SELECT 'folder'::text AS resource_type, @@ -81,6 +82,7 @@ impl PathResolverService { fo.path, fo.parent_id::text, fo.user_id::text, + fo.drive_id, EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at, EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at, NULL::bigint AS size, @@ -102,6 +104,7 @@ impl PathResolverService { END AS path, NULL::text AS parent_id, fi.user_id::text, + fi.drive_id, EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at, EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at, fi.size, @@ -136,6 +139,7 @@ impl PathResolverService { res_path, parent_id, uid, + drive_id, created_at, modified_at, size, @@ -151,6 +155,7 @@ impl PathResolverService { 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, diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index c32a5a20..ada12cd0 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -262,6 +262,11 @@ pub async fn list_favorites_resources( path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Listing handler — drive_id is informational + // and the favorites row doesn't currently + // SELECT it. Path-based lookups never enter + // this code path. + drive_id: uuid::Uuid::nil(), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index da113be5..048e285c 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -701,6 +701,10 @@ pub async fn list_folder_resources( path: String::new(), // cleared — share recipients must not see hierarchy parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Resources listing — drive_id is informational + // here; not selected by the underlying query. + // Path-based lookups never enter this code path. + drive_id: uuid::Uuid::nil(), created_at: row.created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 14dcb189..7d11fb19 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -292,6 +292,11 @@ pub async fn list_recent_resources( path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Listing handler — drive_id is informational + // and the recents row doesn't currently SELECT + // it. Path-based lookups never enter this code + // path. + drive_id: uuid::Uuid::nil(), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index e6b535fd..dc938d35 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort; use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState; +use crate::domain::repositories::drive_repository::DriveRepository; use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; @@ -247,6 +248,29 @@ async fn resolve_webdav_path(state: &Arc, user_id: Uuid, path: &str) - } } +/// 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//…`) +/// 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( + state: &Arc, + user_id: Uuid, +) -> Result { + 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))) +} + async fn handle_webdav_dispatch( state: Arc, req: Request, @@ -405,6 +429,9 @@ async fn handle_propfind( path: "".to_string(), parent_id: None, owner_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, @@ -468,8 +495,11 @@ async fn handle_propfind( Err(_) => {} } } else { - // Fallback: legacy double-query path when PathResolver is unavailable - if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await { + // 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 { assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?; let folder_id = folder.id.clone(); return build_streaming_propfind_response( @@ -484,7 +514,10 @@ async fn handle_propfind( ) .await; } - if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await { + if let Ok(file) = file_retrieval_service + .get_file_by_path(&path, drive_id) + .await + { assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?; let mut buf = Vec::with_capacity(1024); { @@ -688,10 +721,11 @@ async fn handle_proppatch( let is_collection = if path.is_empty() || path == "/" { true } else { + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; state .applications .folder_service - .get_folder_by_path(&path, user.id) + .get_folder_by_path(&path, drive_id) .await .is_ok() }; @@ -776,9 +810,12 @@ async fn handle_get( } } } else { - // Legacy fallback — fetch + ownership check + // Legacy fallback — fetch + ownership check. `drive_id` is the + // path-lookup scope post-D0 (`storage.files.path` repeats across + // drives), derived once from the caller's default drive. + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let f = file_retrieval_service - .get_file_by_path(&path) + .get_file_by_path(&path, drive_id) .await .map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?; assert_owner(f.owner_id.as_deref(), &user.id.to_string(), &path)?; @@ -876,8 +913,11 @@ async fn handle_head( } } - // Fallback: legacy double-query path (with ownership check) - if let Ok(folder) = folder_service.get_folder_by_path(&path, user.id).await { + // 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 { assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?; return Ok(Response::builder() .status(StatusCode::OK) @@ -890,7 +930,7 @@ async fn handle_head( // Try as file — use metadata only, never load content for HEAD let file = file_retrieval_service - .get_file_by_path(&path) + .get_file_by_path(&path, drive_id) .await .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?; @@ -939,15 +979,27 @@ async fn resolve_or_legacy( 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. + let drive_id = state + .drive_repo + .find_default_for_user(user_id) + .await + .ok()? + .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, user_id).await + if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await && folder.owner_id.as_deref() == Some(&user_id_str) { return Some(ResolvedResource::Folder(folder)); } let file_retrieval = &state.applications.file_retrieval_service; - if let Ok(file) = file_retrieval.get_file_by_path(path).await + if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await && file.owner_id.as_deref() == Some(&user_id_str) { return Some(ResolvedResource::File(file)); @@ -1143,8 +1195,9 @@ async fn handle_put( // ── Atomic store: swap the file row onto the ingested blob ── 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, ingested.stored(), &content_type, None) + .update_file_streaming(&path, drive_id, ingested.stored(), &content_type, None) .await; match result { @@ -1198,6 +1251,9 @@ async fn handle_mkcol( // Path is already translated by dispatch (e.g. "My Folder - jared/03/01"). // Walk each segment: the first is the home folder (already exists), // subsequent segments are created as needed with proper parent_id. + // `drive_id` scopes each per-segment path probe to the caller's default + // drive (post-D0 invariant: `storage.folders.path` repeats across drives). + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let mut parent_id: Option = None; let mut accumulated_path = String::new(); @@ -1209,7 +1265,7 @@ async fn handle_mkcol( accumulated_path.push_str(segment); match folder_service - .get_folder_by_path(&accumulated_path, user.id) + .get_folder_by_path(&accumulated_path, drive_id) .await { Ok(existing) => { @@ -1395,6 +1451,11 @@ async fn handle_move( let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; + // `drive_id` scopes every path-based lookup below to the caller's + // default drive (post-D0 invariant: `storage.{files,folders}.path` + // repeats across drives). + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // Check if destination already exists (for Overwrite header compliance) if !overwrite { let dest_exists = if let Some(resolver) = &state.path_resolver { @@ -1404,11 +1465,11 @@ async fn handle_move( .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path, user.id) + .get_folder_by_path(&destination_path, drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path) + .get_file_by_path(&destination_path, drive_id) .await .is_ok() }; @@ -1447,7 +1508,7 @@ async fn handle_move( parent_id: if dest_parent_path.is_empty() { None } else if let Ok(parent) = folder_service - .get_folder_by_path(dest_parent_path, user.id) + .get_folder_by_path(dest_parent_path, drive_id) .await { assert_owner( @@ -1488,7 +1549,7 @@ async fn handle_move( None } else { let parent = folder_service - .get_folder_by_path(dest_parent_path, user.id) + .get_folder_by_path(dest_parent_path, drive_id) .await .map_err(|_| { AppError::not_found(format!( @@ -1606,6 +1667,11 @@ async fn handle_copy( let file_retrieval_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; + // `drive_id` scopes every path-based lookup below to the caller's + // default drive (post-D0 invariant: `storage.{files,folders}.path` + // repeats across drives). + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // Check if destination already exists (for Overwrite header compliance) if !overwrite { let dest_exists = if let Some(resolver) = &state.path_resolver { @@ -1615,11 +1681,11 @@ async fn handle_copy( .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path, user.id) + .get_folder_by_path(&destination_path, drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path) + .get_file_by_path(&destination_path, drive_id) .await .is_ok() }; @@ -1650,7 +1716,7 @@ async fn handle_copy( let target_parent_id = if dest_parent_path.is_empty() { None } else if let Ok(parent) = folder_service - .get_folder_by_path(dest_parent_path, user.id) + .get_folder_by_path(dest_parent_path, drive_id) .await { assert_owner( @@ -1748,10 +1814,11 @@ async fn handle_lock( let is_collection = if path.is_empty() || path == "/" { true } else { + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; state .applications .folder_service - .get_folder_by_path(&path, user.id) + .get_folder_by_path(&path, drive_id) .await .is_ok() }; diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index 063423e6..90134631 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -23,6 +23,7 @@ use std::sync::Arc; use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; use crate::application::services::wopi_lock_service::WopiLockService; use crate::application::services::wopi_token_service::WopiTokenService; +use crate::domain::repositories::drive_repository::DriveRepository; use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; /// Shared state for WOPI handlers. @@ -233,11 +234,31 @@ async fn put_file( }; // ── Atomic store: swap the file row onto the ingested blob ── + // `drive_id` scopes the path-based lookups in `update_file_streaming` + // post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve + // that to the caller's default drive (WOPI today is a single-drive + // editing surface — no drive marker travels in the token). + let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) { + Ok(u) => u, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + let drive_id = match state + .app_state + .drive_repo + .find_default_for_user(claims_sub_uuid) + .await + { + Ok(d) => d.drive.id, + Err(e) => { + tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; let result = state .app_state .applications .file_upload_service - .update_file_streaming(&file.path, ingested.stored(), &content_type, None) + .update_file_streaming(&file.path, drive_id, ingested.stored(), &content_type, None) .await; match result { diff --git a/src/interfaces/nextcloud/basic_auth_middleware.rs b/src/interfaces/nextcloud/basic_auth_middleware.rs index 6fe930bb..c9621c0e 100644 --- a/src/interfaces/nextcloud/basic_auth_middleware.rs +++ b/src/interfaces/nextcloud/basic_auth_middleware.rs @@ -186,7 +186,11 @@ pub async fn basic_auth_middleware( use crate::domain::repositories::drive_repository::DriveRepository; let chroot = match drive_marker.as_deref() { None => { - match state.drive_repo.find_default_for_user(current_user.id).await { + match state + .drive_repo + .find_default_for_user(current_user.id) + .await + { Ok(drive_with_name) => state .applications .folder_service diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index c5b7e5c5..57fd868b 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -358,6 +358,10 @@ fn folder_dto_from_search( path: sr.path.clone(), parent_id: sr.parent_id.clone(), owner_id: None, + // Search result — drive_id is informational. The search row + // doesn't currently SELECT it, and path-based lookups never + // enter this code path. + drive_id: uuid::Uuid::nil(), created_at: sr.created_at, modified_at: sr.modified_at, is_root: sr.is_root, @@ -490,7 +494,6 @@ async fn resolve_scope_folder( body: &str, session: &crate::interfaces::nextcloud::session::NcSession, ) -> Option { - let user = &session.user; let chroot = session.require_chroot().ok()?; let url_user = &session.raw_username; let href = parse_scope_href(body)?; @@ -511,7 +514,7 @@ async fn resolve_scope_folder( let folder_service = &state.applications.folder_service; folder_service - .get_folder_by_path(&internal_path, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .ok() .map(|f| f.id) diff --git a/src/interfaces/nextcloud/trashbin_handler.rs b/src/interfaces/nextcloud/trashbin_handler.rs index c3e40b33..6a7077ed 100644 --- a/src/interfaces/nextcloud/trashbin_handler.rs +++ b/src/interfaces/nextcloud/trashbin_handler.rs @@ -137,9 +137,12 @@ async fn handle_restore( let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?; let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; - let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok() + let dest_taken = file_service + .get_file_by_path(&dest_internal, chroot.drive_id) + .await + .is_ok() || folder_service - .get_folder_by_path(&dest_internal, user.id) + .get_folder_by_path(&dest_internal, chroot.drive_id) .await .is_ok(); if dest_taken { diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index c60103ec..ddb18fda 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -259,6 +259,13 @@ async fn handle_assemble( let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; + // Path-based lookups below scope by `drive_id`. The NC session's + // chroot is always populated for path-scoped handlers (see + // `NcSession::require_chroot`); the FolderDto carries `drive_id` + // post-D0. + let chroot = session.require_chroot()?; + let drive_id = chroot.drive_id; + // TODO(D1): read the caller's default-drive root folder name from // `drives.root_folder_id` instead of hardcoding "Personal". The // constant is correct for every default personal drive provisioned @@ -279,11 +286,19 @@ async fn handle_assemble( let content_type = ingested.content_type.clone(); // Check if file exists (update vs create). - let existing = file_service.get_file_by_path(&internal_path).await; + let existing = file_service + .get_file_by_path(&internal_path, drive_id) + .await; let etag: Option = if existing.is_ok() { let dto = upload_service - .update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime) + .update_file_streaming( + &internal_path, + drive_id, + ingested.stored(), + &content_type, + oc_mtime, + ) .await .map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?; @@ -300,7 +315,7 @@ async fn handle_assemble( use crate::application::ports::folder_ports::FolderUseCase; let parent_folder = match folder_service - .get_folder_by_path(parent_internal, user.id) + .get_folder_by_path(parent_internal, drive_id) .await { Ok(folder) => folder, diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 7910933e..0069558f 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -235,7 +235,7 @@ async fn handle_propfind( // Try to resolve as folder first. let folder_result = folder_service - .get_folder_by_path(&internal_path, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await; if let Ok(folder) = folder_result { @@ -260,7 +260,9 @@ async fn handle_propfind( } // Not a folder — try as a file. - let file_result = file_service.get_file_by_path(&internal_path).await; + 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() { @@ -307,7 +309,6 @@ async fn handle_get( subpath: &str, headers: &axum::http::HeaderMap, ) -> Result, AppError> { - let user = &session.user; let chroot = session.require_chroot()?; // GET on root folder — NC clients use this as an existence check if subpath.is_empty() || subpath == "/" { @@ -324,7 +325,7 @@ async fn handle_get( // Check if path is a folder first (NC clients use GET as existence check) if folder_service - .get_folder_by_path(&internal_path, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .is_ok() { @@ -336,7 +337,7 @@ async fn handle_get( } let file = file_service - .get_file_by_path(&internal_path) + .get_file_by_path(&internal_path, chroot.drive_id) .await .map_err(|_| AppError::not_found("File not found"))?; @@ -386,7 +387,6 @@ async fn handle_head( session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { - let user = &session.user; let chroot = session.require_chroot()?; // HEAD on root folder — NC clients use this as an existence check if subpath.is_empty() || subpath == "/" { @@ -403,7 +403,7 @@ async fn handle_head( // Check if path is a folder (NC clients use HEAD as existence check) if folder_service - .get_folder_by_path(&internal_path, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .is_ok() { @@ -415,7 +415,7 @@ async fn handle_head( } let file = file_service - .get_file_by_path(&internal_path) + .get_file_by_path(&internal_path, chroot.drive_id) .await .map_err(|_| AppError::not_found("File not found"))?; @@ -479,10 +479,13 @@ async fn handle_proppatch( 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).await { + 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, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await { Some((folder.id, "folder")) @@ -687,7 +690,10 @@ async fn handle_put( // bandwidth or disk I/O on a body the server is going to throw away. // The lookup is reused for the create-vs-update distinction below, // so this is also free of an extra DB hit. - let existing = file_service.get_file_by_path(&internal_path).await.ok(); + let existing = file_service + .get_file_by_path(&internal_path, chroot.drive_id) + .await + .ok(); let current_etag = existing.as_ref().map(|f| f.etag.as_str()); if let Some(value) = req @@ -742,7 +748,13 @@ async fn handle_put( // Single streaming path — handles both update and create internally, // swapping the file row onto the already-ingested blob. let stored = upload_service - .update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime) + .update_file_streaming( + &internal_path, + chroot.drive_id, + ingested.stored(), + &content_type, + oc_mtime, + ) .await .map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?; @@ -787,7 +799,7 @@ async fn handle_mkcol( // auto-create doesn't break real clients. if folder_service - .get_folder_by_path(&internal_path, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .is_ok() { @@ -817,7 +829,7 @@ async fn handle_mkcol( }; let parent_folder = match folder_service - .get_folder_by_path(&parent_path, user.id) + .get_folder_by_path(&parent_path, chroot.drive_id) .await { Ok(folder) => folder, @@ -861,7 +873,7 @@ async fn handle_delete( // 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, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await { trash_svc @@ -873,7 +885,10 @@ async fn handle_delete( .body(Body::empty()) .unwrap()); } - if let Ok(file) = file_service.get_file_by_path(&internal_path).await { + 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 @@ -890,7 +905,7 @@ async fn handle_delete( let file_mgmt = &state.applications.file_management_service; if let Ok(folder) = folder_service - .get_folder_by_path(&internal_path, user.id) + .get_folder_by_path(&internal_path, chroot.drive_id) .await { folder_service @@ -904,7 +919,10 @@ async fn handle_delete( .unwrap()); } - if let Ok(file) = file_service.get_file_by_path(&internal_path).await { + 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 @@ -969,11 +987,11 @@ async fn handle_move( // 204-vs-201 selector at response time. let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?; let dest_existing_file = file_service - .get_file_by_path(&dest_internal_precheck) + .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, user.id) + .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(); @@ -1016,7 +1034,10 @@ async fn handle_move( }; // Try as file first. - if let Ok(file) = file_service.get_file_by_path(&src_internal).await { + if let Ok(file) = file_service + .get_file_by_path(&src_internal, chroot.drive_id) + .await + { let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') { Some((parent, name)) => (parent, name), None => ("", dest_subpath.as_str()), @@ -1038,7 +1059,7 @@ async fn handle_move( } else { // Different parent → move. let dest_parent = folder_service - .get_folder_by_path(&dest_parent_internal, user.id) + .get_folder_by_path(&dest_parent_internal, chroot.drive_id) .await .map_err(|_| AppError::not_found("Destination folder not found"))?; @@ -1062,7 +1083,10 @@ async fn handle_move( // existed — RFC 4918 §9.9.4 distinguishes create vs overwrite). let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?; let mut builder = Response::builder().status(final_status); - if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await { + if let Ok(moved) = file_service + .get_file_by_path(&dest_internal, chroot.drive_id) + .await + { // Route through `FileDto::etag` so the MOVE response // matches what a subsequent PROPFIND on the destination // will return — `moved.id` (UUID) would differ from the @@ -1077,7 +1101,7 @@ async fn handle_move( // Try as folder. if let Ok(folder) = folder_service - .get_folder_by_path(&src_internal, user.id) + .get_folder_by_path(&src_internal, chroot.drive_id) .await { let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') { @@ -1107,7 +1131,7 @@ async fn handle_move( } else { // Different parent → move. let dest_parent = folder_service - .get_folder_by_path(&dest_parent_internal, user.id) + .get_folder_by_path(&dest_parent_internal, chroot.drive_id) .await .map_err(|_| AppError::not_found("Destination parent not found"))?; @@ -1634,6 +1658,8 @@ mod tests { path: path.to_string(), parent_id: None, owner_id: None, + // Test stub — path mapper doesn't read drive_id. + drive_id: uuid::Uuid::nil(), created_at: 0, modified_at: 0, is_root: false,