feat(drive): limit NC request by disk

This commit is contained in:
Edouard Vanbelle
2026-06-19 07:49:33 +02:00
parent aa155dffa0
commit 50fb34659e
28 changed files with 591 additions and 179 deletions
+8
View File
@@ -52,6 +52,12 @@ pub struct FolderDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
/// 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<Folder> 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,
+13 -2
View File
@@ -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<i64>,
@@ -104,8 +110,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
caller_id: Uuid,
) -> Result<FileDto, DomainError>;
/// Gets a file by its path (for WebDAV)
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
/// 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<FileDto, DomainError>;
/// Lists files in a folder
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
+12 -8
View File
@@ -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<FolderDto, DomainError>;
/// 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<FolderDto, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<FolderDto>, DomainError>;
+32 -7
View File
@@ -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<StoragePath, DomainError>;
/// Gets the parent folder ID from a path (WebDAV).
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
/// 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<String, DomainError>;
/// Gets a folder ID by its path.
async fn get_folder_id_by_path(&self, folder_path: &str) -> Result<String, DomainError>;
/// 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<String, DomainError>;
/// 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<String, DomainError>;
/// 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<Option<File>, 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<Option<File>, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
let all_files = self.list_files(None).await?;
for file in all_files {
@@ -286,13 +286,16 @@ impl FileRetrievalUseCase for FileRetrievalService {
}
// FIXME no authorisation at all
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result<FileDto, DomainError> {
// 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));
}
@@ -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<i64>,
) -> Result<FileDto, DomainError> {
// 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
}
+4 -4
View File
@@ -85,7 +85,7 @@ impl FolderService {
async fn get_folder_by_path(
&self,
_path: &str,
_user_id: Uuid,
_drive_id: Uuid,
) -> Result<FolderDto, DomainError> {
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<FolderDto, DomainError> {
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(
@@ -101,7 +101,11 @@ impl FileReadPort for MockFileReadPort {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
unimplemented!()
}
@@ -127,7 +131,11 @@ impl FileReadPort for MockFileReadPort {
Ok(0)
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
unimplemented!()
}
+12 -3
View File
@@ -838,11 +838,19 @@ mod tests {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: uuid::Uuid,
) -> Result<String, DomainError> {
unimplemented!()
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: uuid::Uuid,
) -> Result<String, DomainError> {
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<crate::domain::entities::folder::Folder, DomainError> {
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<bool, DomainError> {
unimplemented!()
}
@@ -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,
@@ -500,13 +500,18 @@ impl FileReadPort for MockFileRepository {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: Uuid,
) -> std::result::Result<String, DomainError> {
unimplemented!()
}
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: Uuid,
) -> std::result::Result<String, DomainError> {
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<Folder, DomainError> {
unimplemented!()
}
@@ -773,6 +778,7 @@ impl FolderRepository for MockFolderRepository {
async fn folder_exists(
&self,
_storage_path: &StoragePath,
_drive_id: Uuid,
) -> std::result::Result<bool, DomainError> {
Ok(false)
}
+19 -6
View File
@@ -101,11 +101,19 @@ impl FileReadPort for StubFileReadPort {
Ok(StoragePath::from_string("/"))
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
async fn get_parent_folder_id(
&self,
_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
Ok("root".to_string())
}
async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result<String, DomainError> {
async fn get_folder_id_by_path(
&self,
_folder_path: &str,
_drive_id: Uuid,
) -> Result<String, DomainError> {
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<Folder, DomainError> {
Ok(Folder::default())
}
@@ -299,7 +307,11 @@ impl FolderRepository for StubFolderStoragePort {
Ok(())
}
async fn folder_exists(&self, _storage_path: &StoragePath) -> Result<bool, DomainError> {
async fn folder_exists(
&self,
_storage_path: &StoragePath,
_drive_id: Uuid,
) -> Result<bool, DomainError> {
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<FolderDto, DomainError> {
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<i64>,
@@ -577,7 +590,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase {
Ok(Box::new(empty_stream))
}
async fn get_file_by_path(&self, _path: &str) -> Result<FileDto, DomainError> {
async fn get_file_by_path(&self, _path: &str, _drive_id: Uuid) -> Result<FileDto, DomainError> {
Err(DomainError::not_found("File", "stub"))
}
+39 -1
View File
@@ -29,6 +29,13 @@ pub struct Folder {
/// `None` only for legacy/stub folders; real folders always have an owner.
owner_id: Option<Uuid>,
/// 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<String>,
owner_id: Option<Uuid>,
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,
+7 -2
View File
@@ -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<StoragePath, DomainError>;
/// Gets the parent folder ID from a path (WebDAV).
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
/// 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<String, DomainError>;
}
// ─────────────────────────────────────────────────────
+16 -9
View File
@@ -28,17 +28,17 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Gets a folder by its ID
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
/// 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 - <username>` 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<Folder, DomainError>;
/// 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<bool, DomainError>;
/// 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<bool, DomainError>;
/// Gets the path of a folder
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError>;
@@ -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<String, DomainError> {
async fn get_parent_folder_id(
&self,
path: &str,
drive_id: Uuid,
) -> Result<String, DomainError> {
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<String, DomainError> {
async fn get_folder_id_by_path(
&self,
folder_path: &str,
drive_id: Uuid,
) -> Result<String, DomainError> {
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<Option<File>, 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<Option<File>, 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
}
@@ -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<String>, 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<String>,
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<String>,
Uuid,
Uuid,
i64,
i64,
i64,
@@ -48,6 +61,7 @@ type FolderRowOptUser = (
String,
Option<String>,
Option<Uuid>,
Uuid,
i64,
i64,
i64,
@@ -92,6 +106,7 @@ impl FolderDbRepository {
path: String,
parent_id: Option<String>,
user_id: Option<Uuid>,
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<Folder, DomainError> {
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<Folder, DomainError> {
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<FolderRow> = 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<FolderRow> = 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<FolderRowPaginated> = 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<Vec<Folder>, 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<FolderRowPaginated> = 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<Vec<Folder>, 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<bool, DomainError> {
async fn folder_exists(
&self,
storage_path: &StoragePath,
drive_id: Uuid,
) -> Result<bool, DomainError> {
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<Vec<Folder>, 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<FolderRow> = 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()
}
@@ -64,6 +64,7 @@ impl PathResolverService {
String, // path
Option<String>, // parent_id
Option<String>, // user_id
Uuid, // drive_id
i64, // created_at
i64, // modified_at
Option<i64>, // 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,
@@ -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,
@@ -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,
@@ -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,
+88 -21
View File
@@ -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<AppState>, 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/<uuid>/…`)
/// is tracked separately and will derive `drive_id` directly from the
/// URL instead of going through `find_default_for_user`.
async fn resolve_drive_id_for_native_webdav(
state: &Arc<AppState>,
user_id: Uuid,
) -> Result<Uuid, AppError> {
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<AppState>,
req: Request<Body>,
@@ -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<String> = 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()
};
+22 -1
View File
@@ -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 {
@@ -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
+5 -2
View File
@@ -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<String> {
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)
+5 -2
View File
@@ -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 {
+18 -3
View File
@@ -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<String> = 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,
+51 -25
View File
@@ -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<Response<Body>, 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<Response<Body>, 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,