feat(mounts): external file mounts P1 — pluggable provider + read-only REST
Adds the foundation for external file mounts: admin-configured backends (raw host filesystem in v1; sftp/webdav/… as future provider kinds) surfaced as a folder inside a user's drive. Mount contents are virtual/live-passthrough — read straight from the backend, never stored in storage.files — and are a deliberately separate, limited storage type (no dedup/sharing/trash/search). The feature is dark by default (OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false). P1 scope (this PR): data model, the pluggable provider abstraction, and the read-only REST surface (mount listing + download). Read-write (P2), WebDAV/NextCloud path resolution (P3), and the admin UI (P4) follow. Core model - Mount root = a real storage.folders row; authorization for everything inside collapses onto that folder UUID (ltree-ancestry grant cascade). - Children are virtual, addressed by ext:<mount_id>:<base64url(node_id)> where node_id is provider-owned and opaque to the rest of the system. - A lock-free (arc-swap) MountRegistry maps mount-root UUID -> provider; a thin MountRouter::classify() is the single cheap hook handlers call before parsing an id as a UUID. With no mounts configured it always returns Regular, so existing code paths are unchanged. Added - migrations/20260805000000_external_mounts.sql (storage.external_mounts, kind + config JSONB) - domain/services/external_mount_id (id envelope + virtual etags) - application/ports/external_mount_ports (ExternalMountProvider, MountProviderFactory, repo port) - infrastructure local_fs_mount_provider (tokio::fs, symlink-escape-safe) + factory - application MountRegistry + MountRouter, pg ExternalMountRepository - DI wiring (AppState.mount_router), FeaturesConfig.enable_external_mounts - listing branch (FolderService::list_mount_dir_with_perms + folder_handler) and download branch (FileRetrievalService stat/open mount methods + file_handler) Authorization stays in the service layer (authz.require(Resource::Folder(mount_id))); handlers only classify. Cross-backend operations are out of scope for P1. Tests: 529 unit tests + 5 testcontainers integration tests (real Postgres 17), including end-to-end authorization (owner allowed, stranger denied). Line coverage of the new modules is 84–100% (cargo-llvm-cov). Known gap: file_handler::download_mount_file (HTTP glue) needs a full-app test (P4).
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
//! 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 { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user