Merge pull request #521 from BCNelson/feat/external-file-mounts
feat(mounts): external file mounts — pluggable provider, read-write, WebDAV/NextCloud, admin UI
This commit is contained in:
@@ -101,10 +101,16 @@ mod tests {
|
||||
None,
|
||||
authz.clone(),
|
||||
));
|
||||
let mount_router = Arc::new(
|
||||
crate::application::services::external_mount_router::MountRouter::new(Arc::new(
|
||||
crate::application::services::mount_registry::MountRegistry::empty(),
|
||||
)),
|
||||
);
|
||||
let folder_service = Arc::new(FolderService::new(
|
||||
folder_repo,
|
||||
authz,
|
||||
Arc::new(FileLifecycleService::new()),
|
||||
mount_router,
|
||||
));
|
||||
|
||||
let _batch_service = BatchOperationService::new(
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Classifies file/folder ids into native vs. external-mount handling.
|
||||
//!
|
||||
//! This is the single, cheap hook the service layer calls before any
|
||||
//! `Uuid::parse_str`, so synthetic `ext:` ids and mount-root UUIDs branch to the
|
||||
//! provider while everything else flows to the PostgreSQL repositories unchanged.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::services::mount_registry::{MountConfig, MountRegistry};
|
||||
use crate::domain::services::external_mount_id::{NodeId, is_external_id, parse_child_id};
|
||||
|
||||
/// The result of classifying an id.
|
||||
pub enum ResolvedId {
|
||||
/// Plain native resource (UUID not registered as a mount root, or an
|
||||
/// unrecognized id). Handle exactly as today.
|
||||
Regular,
|
||||
/// A real UUID that IS a mount root. Listing/metadata branch to the provider;
|
||||
/// the row itself still exists natively.
|
||||
MountRoot { cfg: Arc<MountConfig> },
|
||||
/// A synthetic id addressing an entry inside a mount.
|
||||
MountChild {
|
||||
cfg: Arc<MountConfig>,
|
||||
node_id: NodeId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Thin, cloneable classifier over the mount registry.
|
||||
#[derive(Clone)]
|
||||
pub struct MountRouter {
|
||||
registry: Arc<MountRegistry>,
|
||||
}
|
||||
|
||||
impl MountRouter {
|
||||
/// Construct from the shared registry.
|
||||
pub fn new(registry: Arc<MountRegistry>) -> Self {
|
||||
Self { registry }
|
||||
}
|
||||
|
||||
/// Borrow the underlying registry (for path-based resolution / admin reload).
|
||||
pub fn registry(&self) -> &Arc<MountRegistry> {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
/// Fast path: are there no mounts at all? Lets callers skip classification.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.registry.is_empty()
|
||||
}
|
||||
|
||||
/// Classify an id. Never parses a provider `node_id` — only the envelope.
|
||||
pub fn classify(&self, id: &str) -> ResolvedId {
|
||||
if is_external_id(id) {
|
||||
if let Some(child) = parse_child_id(id)
|
||||
&& let Some(cfg) = self.registry.get(&child.mount_id)
|
||||
{
|
||||
return ResolvedId::MountChild {
|
||||
cfg,
|
||||
node_id: child.node_id,
|
||||
};
|
||||
}
|
||||
// Malformed or dangling `ext:` id — fall through to Regular so it
|
||||
// surfaces a clean NotFound downstream rather than hitting the repos.
|
||||
return ResolvedId::Regular;
|
||||
}
|
||||
if let Ok(uuid) = Uuid::parse_str(id)
|
||||
&& let Some(cfg) = self.registry.get(&uuid)
|
||||
{
|
||||
return ResolvedId::MountRoot { cfg };
|
||||
}
|
||||
ResolvedId::Regular
|
||||
}
|
||||
|
||||
/// True when `id` addresses anything inside a mount (root or child).
|
||||
pub fn is_mount_id(&self, id: &str) -> bool {
|
||||
matches!(
|
||||
self.classify(id),
|
||||
ResolvedId::MountRoot { .. } | ResolvedId::MountChild { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Path-based lookup for the protocol surfaces (WebDAV / NextCloud): does
|
||||
/// `internal_path` descend into a mount within `drive_id`? Returns the mount
|
||||
/// config plus the remainder relpath (empty when the path IS the mount root).
|
||||
pub fn find_path(
|
||||
&self,
|
||||
drive_id: uuid::Uuid,
|
||||
internal_path: &str,
|
||||
) -> Option<(Arc<MountConfig>, String)> {
|
||||
self.registry.find_mount_for_path(drive_id, internal_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::application::ports::external_mount_ports::{
|
||||
ExternalMountRecord, ExternalMountRepositoryPort,
|
||||
};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::domain::services::external_mount_id::encode_child_id;
|
||||
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
|
||||
use async_trait::async_trait;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct FakeRepo(Vec<ExternalMountRecord>);
|
||||
#[async_trait]
|
||||
impl ExternalMountRepositoryPort for FakeRepo {
|
||||
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
async fn router_with_mount(mount_id: Uuid, dir: &TempDir) -> MountRouter {
|
||||
let repo = FakeRepo(vec![ExternalMountRecord {
|
||||
mount_folder_id: mount_id,
|
||||
kind: "local_fs".to_string(),
|
||||
config: serde_json::json!({ "path": dir.path().to_str().unwrap() }),
|
||||
name: "M".to_string(),
|
||||
owner_id: Uuid::new_v4(),
|
||||
read_only: false,
|
||||
drive_id: Uuid::new_v4(),
|
||||
mount_path: "Personal/M".to_string(),
|
||||
}]);
|
||||
let reg = Arc::new(MountRegistry::empty());
|
||||
reg.reload(&repo, &DefaultMountProviderFactory::new()).await;
|
||||
MountRouter::new(reg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_classifies_everything_regular() {
|
||||
let router = MountRouter::new(Arc::new(MountRegistry::empty()));
|
||||
assert!(router.is_empty());
|
||||
assert!(matches!(
|
||||
router.classify(&Uuid::new_v4().to_string()),
|
||||
ResolvedId::Regular
|
||||
));
|
||||
assert!(matches!(
|
||||
router.classify("ext:deadbeef:dG9rZW4"),
|
||||
ResolvedId::Regular
|
||||
));
|
||||
assert!(matches!(router.classify("garbage"), ResolvedId::Regular));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classifies_mount_root_uuid() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mount_id = Uuid::new_v4();
|
||||
let router = router_with_mount(mount_id, &dir).await;
|
||||
|
||||
match router.classify(&mount_id.to_string()) {
|
||||
ResolvedId::MountRoot { cfg } => assert_eq!(cfg.mount_id, mount_id),
|
||||
_ => panic!("expected MountRoot"),
|
||||
}
|
||||
assert!(router.is_mount_id(&mount_id.to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classifies_ext_child_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mount_id = Uuid::new_v4();
|
||||
let router = router_with_mount(mount_id, &dir).await;
|
||||
|
||||
let child = encode_child_id(mount_id, "docs/a.txt");
|
||||
match router.classify(&child) {
|
||||
ResolvedId::MountChild { cfg, node_id } => {
|
||||
assert_eq!(cfg.mount_id, mount_id);
|
||||
assert_eq!(node_id.as_str(), "docs/a.txt");
|
||||
}
|
||||
_ => panic!("expected MountChild"),
|
||||
}
|
||||
assert!(router.is_mount_id(&child));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ext_id_for_unregistered_mount_is_regular() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mount_id = Uuid::new_v4();
|
||||
let router = router_with_mount(mount_id, &dir).await;
|
||||
|
||||
// A well-formed ext: id but for a DIFFERENT (unknown) mount → Regular,
|
||||
// so it 404s downstream rather than hitting the repos.
|
||||
let dangling = encode_child_id(Uuid::new_v4(), "x");
|
||||
assert!(matches!(router.classify(&dangling), ResolvedId::Regular));
|
||||
|
||||
// A plain (non-mount) UUID is also Regular.
|
||||
assert!(matches!(
|
||||
router.classify(&Uuid::new_v4().to_string()),
|
||||
ResolvedId::Regular
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Streams an upload straight to an external mount's provider, bypassing the
|
||||
//! content-addressable store entirely (no BLAKE3 / dedup).
|
||||
//!
|
||||
//! The REST upload handler detects a mount destination BEFORE ingesting into the
|
||||
//! CAS and routes here. Authorization stays in this service (the mount-root
|
||||
//! `Create` grant); the handler only classifies and supplies the body stream.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::external_mount_ports::MountByteStream;
|
||||
use crate::application::services::mount_dto::{audit_mount_write, mount_file_dto, mount_parent_id};
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::external_mount_id::NodeId;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Writes uploaded bytes to a mount provider with authorization + auditing.
|
||||
pub struct ExternalUploadService {
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl ExternalUploadService {
|
||||
/// Construct over the ReBAC engine.
|
||||
pub fn new(authz: Arc<PgAclEngine>) -> Self {
|
||||
Self { authz }
|
||||
}
|
||||
|
||||
/// Authorize (`Create` on the mount root) then stream `body` to the provider
|
||||
/// as `name` under `parent_node`. Returns the synthesized `FileDto`.
|
||||
pub async fn write_file(
|
||||
&self,
|
||||
cfg: &MountConfig,
|
||||
parent_node: &NodeId,
|
||||
name: &str,
|
||||
body: MountByteStream<'_>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
Resource::Folder(cfg.mount_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let stat = cfg.provider.write_stream(parent_node, name, body).await?;
|
||||
audit_mount_write("upload", cfg, caller_id, stat.node_id.as_str());
|
||||
let parent = mount_parent_id(cfg, stat.node_id.as_str());
|
||||
Ok(mount_file_dto(cfg, &parent, &stat))
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@ use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
|
||||
use crate::application::services::mount_dto::{audit_mount_write, mount_file_dto, mount_parent_id};
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::application::services::trash_service::TrashService;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::external_mount_id::NodeId;
|
||||
use crate::domain::services::path_service::validate_storage_name;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
@@ -32,6 +36,9 @@ pub struct FileManagementService {
|
||||
authz: Arc<PgAclEngine>,
|
||||
/// Lifecycle hook dispatcher — fired on file created (copy) and deleted.
|
||||
file_lifecycle_hook: Option<Arc<dyn FileLifecycleHook>>,
|
||||
/// External-mount classifier. `None` in stub/test construction → all ids
|
||||
/// are treated as native.
|
||||
mount_router: Option<Arc<MountRouter>>,
|
||||
/// Read/write access hook — fired so Recent reflects "this is the file
|
||||
/// I just copied / renamed / moved", same way the read paths surface
|
||||
/// downloads. Distinct from the lifecycle hook because lifecycle hooks
|
||||
@@ -72,6 +79,7 @@ impl FileManagementService {
|
||||
content_cache,
|
||||
authz,
|
||||
file_lifecycle_hook: None,
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
drive_repo: None,
|
||||
storage_usage: None,
|
||||
@@ -84,6 +92,59 @@ impl FileManagementService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects the external-mount classifier so file mutations can branch
|
||||
/// `ext:` ids to the provider.
|
||||
pub fn with_mount_router(mut self, router: Arc<MountRouter>) -> Self {
|
||||
self.mount_router = Some(router);
|
||||
self
|
||||
}
|
||||
|
||||
/// Classify an id via the mount router (if configured). Returns `Regular`
|
||||
/// when no router is wired.
|
||||
fn classify(&self, id: &str) -> ResolvedId {
|
||||
match &self.mount_router {
|
||||
Some(r) => r.classify(id),
|
||||
None => ResolvedId::Regular,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorize a mutation inside a mount (gates on the mount-root folder).
|
||||
async fn require_mount_perm(
|
||||
&self,
|
||||
cfg: &MountConfig,
|
||||
perm: Permission,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
perm,
|
||||
Resource::Folder(cfg.mount_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a move destination within the same mount as `cfg`. Errors when
|
||||
/// the destination is absent, native, or in a different mount.
|
||||
fn mount_dest_node(
|
||||
&self,
|
||||
cfg: &MountConfig,
|
||||
folder_id: Option<&str>,
|
||||
) -> Result<NodeId, DomainError> {
|
||||
let Some(folder_id) = folder_id else {
|
||||
return Err(cross_boundary_move_err());
|
||||
};
|
||||
match self.classify(folder_id) {
|
||||
ResolvedId::MountRoot { cfg: dest } if dest.mount_id == cfg.mount_id => {
|
||||
Ok(NodeId::default())
|
||||
}
|
||||
ResolvedId::MountChild { cfg: dest, node_id } if dest.mount_id == cfg.mount_id => {
|
||||
Ok(node_id)
|
||||
}
|
||||
_ => Err(cross_boundary_move_err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the read/write access hook (Recent list recorder).
|
||||
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
||||
self.resource_access_hook = Some(hook);
|
||||
@@ -316,6 +377,29 @@ impl FileManagementUseCase for FileManagementService {
|
||||
caller_id: Uuid,
|
||||
folder_id: Option<String>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
// External mount: moves stay within one mount; cross-backend is forbidden.
|
||||
match self.classify(file_id) {
|
||||
ResolvedId::Regular => {
|
||||
if let Some(dst) = folder_id.as_deref()
|
||||
&& !matches!(self.classify(dst), ResolvedId::Regular)
|
||||
{
|
||||
return Err(cross_boundary_move_err());
|
||||
}
|
||||
}
|
||||
ResolvedId::MountRoot { .. } => return Err(DomainError::not_found("File", file_id)),
|
||||
ResolvedId::MountChild { cfg, node_id } => {
|
||||
let dest = self.mount_dest_node(&cfg, folder_id.as_deref())?;
|
||||
self.require_mount_perm(&cfg, Permission::Update, caller_id)
|
||||
.await?;
|
||||
self.require_mount_perm(&cfg, Permission::Create, caller_id)
|
||||
.await?;
|
||||
let stat = cfg.provider.move_within(&node_id, &dest).await?;
|
||||
audit_mount_write("move", &cfg, caller_id, stat.node_id.as_str());
|
||||
let parent = mount_parent_id(&cfg, stat.node_id.as_str());
|
||||
return Ok(mount_file_dto(&cfg, &parent, &stat));
|
||||
}
|
||||
}
|
||||
|
||||
// Move = Update on the file + Create on the target folder (if any).
|
||||
self.require_file_perm(file_id, Permission::Update, caller_id)
|
||||
.await?;
|
||||
@@ -460,12 +544,32 @@ impl FileManagementUseCase for FileManagementService {
|
||||
caller_id: Uuid,
|
||||
new_name: &str,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
if let ResolvedId::MountChild { cfg, node_id } = self.classify(file_id) {
|
||||
if let Err(reason) = validate_storage_name(new_name) {
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid file name '{new_name}': {reason}"
|
||||
)));
|
||||
}
|
||||
self.require_mount_perm(&cfg, Permission::Update, caller_id)
|
||||
.await?;
|
||||
let stat = cfg.provider.rename(&node_id, new_name).await?;
|
||||
audit_mount_write("rename", &cfg, caller_id, stat.node_id.as_str());
|
||||
let parent = mount_parent_id(&cfg, stat.node_id.as_str());
|
||||
return Ok(mount_file_dto(&cfg, &parent, &stat));
|
||||
}
|
||||
self.require_file_perm(file_id, Permission::Update, caller_id)
|
||||
.await?;
|
||||
self.rename_file(file_id, new_name, caller_id).await
|
||||
}
|
||||
|
||||
async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
if let ResolvedId::MountChild { cfg, node_id } = self.classify(id) {
|
||||
self.require_mount_perm(&cfg, Permission::Delete, caller_id)
|
||||
.await?;
|
||||
cfg.provider.delete(&node_id).await?;
|
||||
audit_mount_write("delete", &cfg, caller_id, node_id.as_str());
|
||||
return Ok(());
|
||||
}
|
||||
self.require_file_perm(id, Permission::Delete, caller_id)
|
||||
.await?;
|
||||
self.delete_file(id).await
|
||||
@@ -482,6 +586,15 @@ impl FileManagementUseCase for FileManagementService {
|
||||
id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<bool, DomainError> {
|
||||
// External mount: permanent provider delete (mounts have no trash).
|
||||
if let ResolvedId::MountChild { cfg, node_id } = self.classify(id) {
|
||||
self.require_mount_perm(&cfg, Permission::Delete, caller_id)
|
||||
.await?;
|
||||
cfg.provider.delete(&node_id).await?;
|
||||
audit_mount_write("delete", &cfg, caller_id, node_id.as_str());
|
||||
return Ok(false); // permanently deleted (no trash)
|
||||
}
|
||||
|
||||
self.require_file_perm(id, Permission::Delete, caller_id)
|
||||
.await?;
|
||||
// Step 1: Try trash (soft delete — file row stays, blob stays referenced)
|
||||
@@ -552,3 +665,12 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Error for a move/copy that would cross a storage backend boundary
|
||||
/// (mount ↔ native, or between two different mounts). Forbidden in v1.
|
||||
fn cross_boundary_move_err() -> DomainError {
|
||||
DomainError::operation_not_supported(
|
||||
"File",
|
||||
"moving between external mounts and regular storage is not supported",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::blob_storage_ports::BlobStream;
|
||||
use crate::application::ports::external_mount_ports::MountStat;
|
||||
use crate::application::ports::file_ports::{
|
||||
FileRetrievalUseCase, OptimizedFileContent, RangeContent,
|
||||
};
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::external_mount_id::NodeId;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::image_transcode_service::{
|
||||
@@ -36,6 +40,9 @@ pub struct FileRetrievalService {
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
transcode: Option<Arc<ImageTranscodeService>>,
|
||||
authz: Option<Arc<PgAclEngine>>,
|
||||
/// External-mount classifier for path-based resolution (WebDAV/NextCloud).
|
||||
/// `None` in the simple/test constructor → no mount support.
|
||||
mount_router: Option<Arc<crate::application::services::external_mount_router::MountRouter>>,
|
||||
/// Optional read-event observer. Currently fans out to the Recent-list
|
||||
/// recorder; future observers (audit trail, "last seen by", …) attach
|
||||
/// to the same hook so service code only knows the trait, not the impl.
|
||||
@@ -53,6 +60,7 @@ impl FileRetrievalService {
|
||||
content_cache: None,
|
||||
transcode: None,
|
||||
authz: None,
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
@@ -70,10 +78,21 @@ impl FileRetrievalService {
|
||||
content_cache: Some(content_cache),
|
||||
transcode: Some(transcode),
|
||||
authz: Some(authz),
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Injects the external-mount classifier so path-based lookups
|
||||
/// (`get_file_by_path`) can resolve mount paths to the provider.
|
||||
pub fn with_mount_router(
|
||||
mut self,
|
||||
router: Arc<crate::application::services::external_mount_router::MountRouter>,
|
||||
) -> Self {
|
||||
self.mount_router = Some(router);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: attach a [`ResourceAccessHook`] that fires after every
|
||||
/// authorised `_with_perms` read. Without it the service is silent —
|
||||
/// existing behaviour for stub / test paths.
|
||||
@@ -82,6 +101,24 @@ impl FileRetrievalService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Test-only constructor: authorization engine without the cache/transcode
|
||||
/// tiers. The external-mount read methods only consult `authz` + the
|
||||
/// provider, so this is sufficient to exercise their authorization.
|
||||
#[cfg(all(test, integration_tests))]
|
||||
pub(crate) fn new_with_authz_for_test(
|
||||
file_read: Arc<FileBlobReadRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_read,
|
||||
content_cache: None,
|
||||
transcode: None,
|
||||
authz: Some(authz),
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the access hook if registered. Called from every `_with_perms`
|
||||
/// read after the authZ + lookup has succeeded (never on failure
|
||||
/// paths — denied reads must not surface in Recent).
|
||||
@@ -176,6 +213,66 @@ impl FileRetrievalService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Authorize then `stat` a file inside an external mount. Authorization
|
||||
/// collapses onto the mount-root folder (a `Read` grant there covers
|
||||
/// everything in the mount).
|
||||
pub async fn stat_mount_file_with_perms(
|
||||
&self,
|
||||
cfg: &MountConfig,
|
||||
node_id: &NodeId,
|
||||
caller_id: Uuid,
|
||||
) -> Result<MountStat, DomainError> {
|
||||
let authz = self.authz.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
||||
})?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Resource::Folder(cfg.mount_id),
|
||||
)
|
||||
.await?;
|
||||
cfg.provider.stat(node_id).await
|
||||
}
|
||||
|
||||
/// Authorize then open a (optionally ranged) read stream over a mount file.
|
||||
/// `range` is `(start, end_inclusive_opt)`.
|
||||
pub async fn open_mount_file_with_perms(
|
||||
&self,
|
||||
cfg: &MountConfig,
|
||||
node_id: &NodeId,
|
||||
caller_id: Uuid,
|
||||
range: Option<(u64, Option<u64>)>,
|
||||
) -> Result<BlobStream, DomainError> {
|
||||
let authz = self.authz.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
||||
})?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Resource::Folder(cfg.mount_id),
|
||||
)
|
||||
.await?;
|
||||
cfg.provider.open_read_stream(node_id, range).await
|
||||
}
|
||||
|
||||
/// If `id` is an `ext:` mount FILE id, return the mount config + node id.
|
||||
/// `None` for native ids, mount roots, or when no router is wired.
|
||||
fn mount_file_node(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Option<(
|
||||
Arc<crate::application::services::mount_registry::MountConfig>,
|
||||
NodeId,
|
||||
)> {
|
||||
use crate::application::services::external_mount_router::ResolvedId;
|
||||
match self.mount_router.as_ref()?.classify(id) {
|
||||
ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to transcode image content to WebP and return transcoded variant.
|
||||
async fn try_transcode(
|
||||
&self,
|
||||
@@ -451,6 +548,26 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
// `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.
|
||||
// External mount: a path descending past a mount root resolves on the
|
||||
// provider (stat). The mount root itself has no file at its path.
|
||||
if let Some(router) = &self.mount_router
|
||||
&& let Some((cfg, remainder)) = router.find_path(drive_id, path)
|
||||
&& !remainder.is_empty()
|
||||
{
|
||||
let node = cfg.provider.resolve_path(&remainder);
|
||||
let stat = cfg.provider.stat(&node).await?;
|
||||
if stat.is_dir {
|
||||
return Err(DomainError::not_found("File", path));
|
||||
}
|
||||
let parent = crate::application::services::mount_dto::mount_parent_id(
|
||||
&cfg,
|
||||
stat.node_id.as_str(),
|
||||
);
|
||||
return Ok(crate::application::services::mount_dto::mount_file_dto(
|
||||
&cfg, &parent, &stat,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(file) = self.file_read.find_file_by_path(path, drive_id).await? {
|
||||
return Ok(FileDto::from(file));
|
||||
}
|
||||
@@ -488,6 +605,11 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
if let Some((cfg, node)) = self.mount_file_node(id) {
|
||||
let s = cfg.provider.open_read_stream(&node, None).await?;
|
||||
// `Pin<Box<dyn Stream>>` is itself a `Stream`, so re-box it.
|
||||
return Ok(Box::new(s));
|
||||
}
|
||||
self.file_read.get_file_stream(id).await
|
||||
}
|
||||
|
||||
@@ -548,6 +670,13 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
if let Some((cfg, node)) = self.mount_file_node(id) {
|
||||
// The native range convention is exclusive-end; the provider wants
|
||||
// an inclusive end.
|
||||
let range = Some((start, end.map(|e| e.saturating_sub(1))));
|
||||
let s = cfg.provider.open_read_stream(&node, range).await?;
|
||||
return Ok(Box::new(s));
|
||||
}
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
|
||||
@@ -596,6 +725,58 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
// External mount: list files from the provider (WebDAV/NextCloud
|
||||
// PROPFIND Depth:1 file loop). Authz collapses on the mount root.
|
||||
// Keyset pagination by name mirrors `paginate_mount_entries` —
|
||||
// provider order isn't guaranteed, so sort before slicing on
|
||||
// `after_name`.
|
||||
if let Some(fid) = folder_id
|
||||
&& let Some(router) = &self.mount_router
|
||||
{
|
||||
use crate::application::services::external_mount_router::ResolvedId;
|
||||
let resolved = match router.classify(fid) {
|
||||
ResolvedId::Regular => None,
|
||||
ResolvedId::MountRoot { cfg } => Some((cfg, NodeId::default())),
|
||||
ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)),
|
||||
};
|
||||
if let Some((cfg, node)) = resolved {
|
||||
if let Some(authz) = &self.authz {
|
||||
authz
|
||||
.require(
|
||||
Subject::User(owner_id),
|
||||
Permission::Read,
|
||||
Resource::Folder(cfg.mount_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let mut entries: Vec<_> = cfg
|
||||
.provider
|
||||
.list_dir(&node)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.name.to_lowercase());
|
||||
let start = match after_name {
|
||||
Some(name) => entries
|
||||
.iter()
|
||||
.position(|e| name.eq_ignore_ascii_case(&e.name))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let files: Vec<FileDto> = entries
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(limit.max(0) as usize)
|
||||
.map(|e| {
|
||||
crate::application::services::mount_dto::mount_entry_file_dto(&cfg, fid, &e)
|
||||
})
|
||||
.collect();
|
||||
return Ok(files);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-D0: every file lives in a folder — `storage.files.folder_id`
|
||||
// is NOT NULL. `folder_id = None` means the caller is asking for
|
||||
// "root-level files", which by design return an empty set: the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ pub mod delta_upload_service;
|
||||
pub mod device_auth_service;
|
||||
pub mod drive_management_service;
|
||||
pub mod external_identity_service;
|
||||
pub mod external_mount_router;
|
||||
pub mod external_upload_service;
|
||||
pub mod favorites_service;
|
||||
pub mod file_lifecycle_service;
|
||||
pub mod file_management_service;
|
||||
@@ -18,6 +20,8 @@ pub mod file_use_case_factory;
|
||||
pub mod folder_service;
|
||||
pub mod i18n_application_service;
|
||||
pub mod magic_link_invite_service;
|
||||
pub mod mount_dto;
|
||||
pub mod mount_registry;
|
||||
pub mod music_service;
|
||||
pub mod nextcloud_file_id_service;
|
||||
pub mod nextcloud_login_flow_service;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Builders that synthesize `FolderDto` / `FileDto` from a provider [`MountStat`].
|
||||
//!
|
||||
//! Mount entries have no `storage.folders`/`storage.files` row, so the normal
|
||||
//! `FolderDto::from(Folder)` path doesn't apply. These helpers produce the same
|
||||
//! DTO shape from a provider stat plus the mount config, with a synthetic `ext:`
|
||||
//! id and a virtual etag. Shared by the folder/file services and the handlers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::ports::external_mount_ports::{MountEntry, MountStat};
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::domain::services::external_mount_id::{
|
||||
encode_child_id, virtual_file_etag, virtual_folder_etag,
|
||||
};
|
||||
|
||||
/// Final path segment of a node id (the display name).
|
||||
fn node_name(node_id: &str) -> &str {
|
||||
node_id.rsplit('/').next().unwrap_or(node_id)
|
||||
}
|
||||
|
||||
/// Emit the structured audit line for a mount mutation (per AGENTS.md). Every
|
||||
/// write op (upload / mkdir / rename / delete / move) calls this.
|
||||
pub fn audit_mount_write(action: &str, cfg: &MountConfig, caller_id: Uuid, node_id: &str) {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_mount.write",
|
||||
action,
|
||||
mount_id = %cfg.mount_id,
|
||||
caller_id = %caller_id,
|
||||
node_id = %node_id,
|
||||
reason = "external_mount_op",
|
||||
"👮🏻♂️ external mount mutation",
|
||||
);
|
||||
}
|
||||
|
||||
/// The id-string of a mount entry's parent: the parent's `ext:` id, or the
|
||||
/// mount-root folder UUID when the entry is a direct child of the root.
|
||||
pub fn mount_parent_id(cfg: &MountConfig, node_id: &str) -> String {
|
||||
match node_id.rsplit_once('/') {
|
||||
Some((parent, _)) => encode_child_id(cfg.mount_id, parent),
|
||||
None => cfg.mount_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `FolderDto` for a mount directory from its stat. `parent_id` is the
|
||||
/// id-string of the containing directory (mount-root UUID or an `ext:` id).
|
||||
pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FolderDto {
|
||||
FolderDto {
|
||||
etag: virtual_folder_etag(stat.modified_at),
|
||||
id: encode_child_id(cfg.mount_id, stat.node_id.clone()),
|
||||
name: node_name(stat.node_id.as_str()).to_owned(),
|
||||
path: String::new(),
|
||||
parent_id: Some(parent_id.to_owned()),
|
||||
drive_id: cfg.drive_id,
|
||||
created_at: stat.created_at,
|
||||
modified_at: stat.modified_at,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `FolderDto` from a directory listing entry. `parent_id` is the
|
||||
/// id-string of the directory being listed.
|
||||
pub fn mount_entry_folder_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEntry) -> FolderDto {
|
||||
FolderDto {
|
||||
etag: virtual_folder_etag(entry.modified_at),
|
||||
id: encode_child_id(cfg.mount_id, entry.node_id.clone()),
|
||||
name: entry.name.clone(),
|
||||
path: String::new(),
|
||||
parent_id: Some(parent_id.to_owned()),
|
||||
drive_id: cfg.drive_id,
|
||||
created_at: entry.created_at,
|
||||
modified_at: entry.modified_at,
|
||||
is_root: false,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `FileDto` from a directory listing entry (mime sniffed from name).
|
||||
pub fn mount_entry_file_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEntry) -> FileDto {
|
||||
let name = entry.name.as_str();
|
||||
let mime = mime_guess::from_path(name)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
FileDto {
|
||||
id: encode_child_id(cfg.mount_id, entry.node_id.clone()),
|
||||
name: name.to_owned(),
|
||||
path: String::new(),
|
||||
size: entry.size,
|
||||
mime_type: Arc::from(mime.as_str()),
|
||||
folder_id: Some(parent_id.to_owned()),
|
||||
created_at: entry.created_at,
|
||||
modified_at: entry.modified_at,
|
||||
icon_class: Arc::from(icon_class_for(name, &mime)),
|
||||
icon_special_class: Arc::from(icon_special_class_for(name, &mime)),
|
||||
category: Arc::from(category_for(name, &mime)),
|
||||
size_formatted: format_file_size(entry.size),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(entry.size, entry.modified_at),
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `FileDto` for a mount file from its stat. Virtual files have no blob
|
||||
/// hash (`content_hash` empty) and a size+mtime etag.
|
||||
pub fn mount_file_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> FileDto {
|
||||
let name = node_name(stat.node_id.as_str());
|
||||
let mime = stat.mime_type.as_str();
|
||||
FileDto {
|
||||
id: encode_child_id(cfg.mount_id, stat.node_id.clone()),
|
||||
name: name.to_owned(),
|
||||
path: String::new(),
|
||||
size: stat.size,
|
||||
mime_type: Arc::from(mime),
|
||||
folder_id: Some(parent_id.to_owned()),
|
||||
created_at: stat.created_at,
|
||||
modified_at: stat.modified_at,
|
||||
icon_class: Arc::from(icon_class_for(name, mime)),
|
||||
icon_special_class: Arc::from(icon_special_class_for(name, mime)),
|
||||
category: Arc::from(category_for(name, mime)),
|
||||
size_formatted: format_file_size(stat.size),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(stat.size, stat.modified_at),
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! In-memory registry of configured external mounts.
|
||||
//!
|
||||
//! Holds, per mount-root folder UUID, the constructed provider plus the metadata
|
||||
//! the service layer needs to authorize and synthesize DTOs. Reads are lock-free
|
||||
//! (`arc-swap`) so the hot path ("is this UUID a mount root?") never blocks; the
|
||||
//! whole index is rebuilt on admin mutation via [`MountRegistry::reload`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::external_mount_ports::{
|
||||
ExternalMountProvider, ExternalMountRepositoryPort, MountProviderFactory,
|
||||
};
|
||||
|
||||
/// One configured mount, with its live provider.
|
||||
pub struct MountConfig {
|
||||
/// Mount-root folder UUID — the mount's identity and the authz resource.
|
||||
pub mount_id: Uuid,
|
||||
/// Provider kind.
|
||||
pub kind: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Owner of the mount configuration.
|
||||
pub owner_id: Uuid,
|
||||
/// Drive the mount root belongs to.
|
||||
pub drive_id: Uuid,
|
||||
/// Whether the mount refuses mutations.
|
||||
pub read_only: bool,
|
||||
/// Materialized internal path of the mount root (e.g. `"Personal/Media"`),
|
||||
/// used by path-based resolution for WebDAV / NextCloud.
|
||||
pub mount_path: String,
|
||||
/// The bound provider for this mount's backend.
|
||||
pub provider: Arc<dyn ExternalMountProvider>,
|
||||
}
|
||||
|
||||
/// Immutable snapshot swapped atomically on reload.
|
||||
#[derive(Default)]
|
||||
struct MountIndex {
|
||||
/// mount-root folder UUID → config.
|
||||
by_folder: HashMap<Uuid, Arc<MountConfig>>,
|
||||
/// (drive_id, mount_path) → mount-root UUID, for path resolution (P3).
|
||||
by_path: HashMap<(Uuid, String), Uuid>,
|
||||
}
|
||||
|
||||
/// Lock-free registry of mounts.
|
||||
pub struct MountRegistry {
|
||||
inner: ArcSwap<MountIndex>,
|
||||
}
|
||||
|
||||
impl Default for MountRegistry {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MountRegistry {
|
||||
/// An empty registry (no mounts).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: ArcSwap::from_pointee(MountIndex::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a mount by its root folder UUID.
|
||||
pub fn get(&self, mount_id: &Uuid) -> Option<Arc<MountConfig>> {
|
||||
self.inner.load().by_folder.get(mount_id).cloned()
|
||||
}
|
||||
|
||||
/// Is this UUID the root of a configured mount?
|
||||
pub fn is_mount_root(&self, id: &Uuid) -> bool {
|
||||
self.inner.load().by_folder.contains_key(id)
|
||||
}
|
||||
|
||||
/// True when no mounts are configured (lets callers skip work entirely).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inner.load().by_folder.is_empty()
|
||||
}
|
||||
|
||||
/// Find the mount whose root path is a segment-aligned prefix of
|
||||
/// `internal_path` within `drive_id`. Returns the config plus the remainder
|
||||
/// path relative to the mount root (`""` when the path IS the mount root).
|
||||
///
|
||||
/// Used by path-based resolution (WebDAV / NextCloud) in P3.
|
||||
pub fn find_mount_for_path(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
internal_path: &str,
|
||||
) -> Option<(Arc<MountConfig>, String)> {
|
||||
let index = self.inner.load();
|
||||
// Normalize away a leading slash: materialized folder paths arrive both
|
||||
// as `Personal/Media` (raw `folders.path`) and `/Personal/Media`
|
||||
// (FolderDto / WebDAV internal paths). The index keys are stored without
|
||||
// a leading slash (see `reload`).
|
||||
let internal_path = internal_path.trim_start_matches('/');
|
||||
// Walk ancestor paths from the full path up to the root, longest first,
|
||||
// so the deepest matching mount wins.
|
||||
let mut candidate = internal_path;
|
||||
loop {
|
||||
if let Some(mount_id) = index.by_path.get(&(drive_id, candidate.to_string()))
|
||||
&& let Some(cfg) = index.by_folder.get(mount_id)
|
||||
{
|
||||
let remainder = internal_path
|
||||
.strip_prefix(candidate)
|
||||
.map(|r| r.trim_start_matches('/').to_string())
|
||||
.unwrap_or_default();
|
||||
return Some((cfg.clone(), remainder));
|
||||
}
|
||||
match candidate.rsplit_once('/') {
|
||||
Some((parent, _)) => candidate = parent,
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the registry from persisted records, constructing each provider
|
||||
/// via the factory. A mount whose provider fails to build is skipped (logged)
|
||||
/// rather than failing the whole reload.
|
||||
pub async fn reload(
|
||||
&self,
|
||||
repo: &dyn ExternalMountRepositoryPort,
|
||||
factory: &dyn MountProviderFactory,
|
||||
) {
|
||||
let records = match repo.list_all().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
target: "oxicloud::external_mounts",
|
||||
"failed to load external mounts: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut by_folder = HashMap::with_capacity(records.len());
|
||||
let mut by_path = HashMap::with_capacity(records.len());
|
||||
for rec in records {
|
||||
let provider = match factory.build(&rec.kind, &rec.config).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
target: "oxicloud::external_mounts",
|
||||
mount_id = %rec.mount_folder_id,
|
||||
kind = %rec.kind,
|
||||
"skipping mount: provider build failed: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Store the path key without a leading slash so lookups normalize
|
||||
// consistently (see `find_mount_for_path`).
|
||||
by_path.insert(
|
||||
(
|
||||
rec.drive_id,
|
||||
rec.mount_path.trim_start_matches('/').to_string(),
|
||||
),
|
||||
rec.mount_folder_id,
|
||||
);
|
||||
by_folder.insert(
|
||||
rec.mount_folder_id,
|
||||
Arc::new(MountConfig {
|
||||
mount_id: rec.mount_folder_id,
|
||||
kind: rec.kind,
|
||||
name: rec.name,
|
||||
owner_id: rec.owner_id,
|
||||
drive_id: rec.drive_id,
|
||||
read_only: rec.read_only,
|
||||
mount_path: rec.mount_path,
|
||||
provider,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let count = by_folder.len();
|
||||
self.inner
|
||||
.store(Arc::new(MountIndex { by_folder, by_path }));
|
||||
tracing::info!(
|
||||
target: "oxicloud::external_mounts",
|
||||
count, "external mount registry loaded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::application::ports::external_mount_ports::{
|
||||
ExternalMountRecord, ExternalMountRepositoryPort,
|
||||
};
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
|
||||
use async_trait::async_trait;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct FakeRepo {
|
||||
records: Vec<ExternalMountRecord>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalMountRepositoryPort for FakeRepo {
|
||||
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError> {
|
||||
Ok(self.records.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn record(
|
||||
mount_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
mount_path: &str,
|
||||
path: &Path,
|
||||
) -> ExternalMountRecord {
|
||||
ExternalMountRecord {
|
||||
mount_folder_id: mount_id,
|
||||
kind: "local_fs".to_string(),
|
||||
config: serde_json::json!({ "path": path.to_str().unwrap() }),
|
||||
name: "Test Mount".to_string(),
|
||||
owner_id: Uuid::new_v4(),
|
||||
read_only: false,
|
||||
drive_id,
|
||||
mount_path: mount_path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_is_inert() {
|
||||
let r = MountRegistry::empty();
|
||||
assert!(r.is_empty());
|
||||
assert!(!r.is_mount_root(&Uuid::new_v4()));
|
||||
assert!(r.get(&Uuid::new_v4()).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_populates_from_records() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mount_id = Uuid::new_v4();
|
||||
let drive_id = Uuid::new_v4();
|
||||
let repo = FakeRepo {
|
||||
records: vec![record(mount_id, drive_id, "Personal/Media", dir.path())],
|
||||
};
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
|
||||
let reg = MountRegistry::empty();
|
||||
reg.reload(&repo, &factory).await;
|
||||
|
||||
assert!(!reg.is_empty());
|
||||
assert!(reg.is_mount_root(&mount_id));
|
||||
let cfg = reg.get(&mount_id).expect("present");
|
||||
assert_eq!(cfg.kind, "local_fs");
|
||||
assert_eq!(cfg.drive_id, drive_id);
|
||||
assert_eq!(cfg.mount_path, "Personal/Media");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_skips_mount_whose_provider_fails_to_build() {
|
||||
let mount_id = Uuid::new_v4();
|
||||
// Point at a path that doesn't exist → LocalFsMountProvider::new errors.
|
||||
let repo = FakeRepo {
|
||||
records: vec![ExternalMountRecord {
|
||||
mount_folder_id: mount_id,
|
||||
kind: "local_fs".to_string(),
|
||||
config: serde_json::json!({ "path": "/nonexistent/path/xyz-123" }),
|
||||
name: "Bad".to_string(),
|
||||
owner_id: Uuid::new_v4(),
|
||||
read_only: false,
|
||||
drive_id: Uuid::new_v4(),
|
||||
mount_path: "Personal/Bad".to_string(),
|
||||
}],
|
||||
};
|
||||
let factory = DefaultMountProviderFactory::new();
|
||||
let reg = MountRegistry::empty();
|
||||
reg.reload(&repo, &factory).await;
|
||||
// The bad mount is skipped, not fatal.
|
||||
assert!(reg.is_empty());
|
||||
assert!(!reg.is_mount_root(&mount_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_mount_for_path_matches_prefix_and_remainder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mount_id = Uuid::new_v4();
|
||||
let drive_id = Uuid::new_v4();
|
||||
let repo = FakeRepo {
|
||||
records: vec![record(mount_id, drive_id, "Personal/Media", dir.path())],
|
||||
};
|
||||
let reg = MountRegistry::empty();
|
||||
reg.reload(&repo, &DefaultMountProviderFactory::new()).await;
|
||||
|
||||
// Exact match → empty remainder.
|
||||
let (cfg, rem) = reg
|
||||
.find_mount_for_path(drive_id, "Personal/Media")
|
||||
.expect("exact match");
|
||||
assert_eq!(cfg.mount_id, mount_id);
|
||||
assert_eq!(rem, "");
|
||||
|
||||
// Nested path → remainder is the suffix.
|
||||
let (_cfg, rem) = reg
|
||||
.find_mount_for_path(drive_id, "Personal/Media/docs/a.txt")
|
||||
.expect("nested match");
|
||||
assert_eq!(rem, "docs/a.txt");
|
||||
|
||||
// Non-matching path within the drive → None.
|
||||
assert!(
|
||||
reg.find_mount_for_path(drive_id, "Personal/Other")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Same path but a DIFFERENT drive → None (drive-scoped).
|
||||
assert!(
|
||||
reg.find_mount_for_path(Uuid::new_v4(), "Personal/Media")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// A sibling that merely shares a name prefix must NOT match
|
||||
// (segment-aligned only).
|
||||
assert!(
|
||||
reg.find_mount_for_path(drive_id, "Personal/MediaLibrary")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_replaces_previous_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let drive = Uuid::new_v4();
|
||||
let first = Uuid::new_v4();
|
||||
let reg = MountRegistry::empty();
|
||||
|
||||
reg.reload(
|
||||
&FakeRepo {
|
||||
records: vec![record(first, drive, "Personal/A", dir.path())],
|
||||
},
|
||||
&DefaultMountProviderFactory::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(reg.is_mount_root(&first));
|
||||
|
||||
// A second reload with a different mount set replaces the first entirely.
|
||||
let second = Uuid::new_v4();
|
||||
reg.reload(
|
||||
&FakeRepo {
|
||||
records: vec![record(second, drive, "Personal/B", dir.path())],
|
||||
},
|
||||
&DefaultMountProviderFactory::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(reg.is_mount_root(&second));
|
||||
assert!(
|
||||
!reg.is_mount_root(&first),
|
||||
"stale mount must be gone after reload"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_mount_for_path_deepest_wins() {
|
||||
let outer = TempDir::new().unwrap();
|
||||
let inner = TempDir::new().unwrap();
|
||||
let drive_id = Uuid::new_v4();
|
||||
let outer_id = Uuid::new_v4();
|
||||
let inner_id = Uuid::new_v4();
|
||||
let repo = FakeRepo {
|
||||
records: vec![
|
||||
record(outer_id, drive_id, "Personal", outer.path()),
|
||||
record(inner_id, drive_id, "Personal/Media", inner.path()),
|
||||
],
|
||||
};
|
||||
let reg = MountRegistry::empty();
|
||||
reg.reload(&repo, &DefaultMountProviderFactory::new()).await;
|
||||
|
||||
// A path under the deeper mount resolves to the deeper mount.
|
||||
let (cfg, rem) = reg
|
||||
.find_mount_for_path(drive_id, "Personal/Media/x")
|
||||
.expect("match");
|
||||
assert_eq!(cfg.mount_id, inner_id);
|
||||
assert_eq!(rem, "x");
|
||||
|
||||
// A path under the shallower mount (but not the deeper one) resolves
|
||||
// to the shallower mount.
|
||||
let (cfg, rem) = reg
|
||||
.find_mount_for_path(drive_id, "Personal/Other/y")
|
||||
.expect("match");
|
||||
assert_eq!(cfg.mount_id, outer_id);
|
||||
assert_eq!(rem, "Other/y");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user