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:
Bradley Nelson
2026-06-24 23:52:01 -06:00
parent 1d0fa27991
commit 3c31695579
24 changed files with 3941 additions and 29 deletions
@@ -0,0 +1,154 @@
//! PostgreSQL persistence for external mount configuration.
//!
//! P1 only needs [`ExternalMountRepositoryPort::list_all`] to (re)build the
//! in-memory registry; admin CRUD lands with P4.
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::application::ports::external_mount_ports::{
ExternalMountRecord, ExternalMountRepositoryPort,
};
use crate::domain::errors::DomainError;
/// PostgreSQL implementation of [`ExternalMountRepositoryPort`].
pub struct ExternalMountPgRepository {
pool: Arc<PgPool>,
}
impl ExternalMountPgRepository {
/// Construct over a connection pool.
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl ExternalMountRepositoryPort for ExternalMountPgRepository {
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError> {
// Join each mount to its (non-trashed) root folder to pick up the
// drive scope and the materialized path needed for path resolution.
let rows = sqlx::query(
r#"
SELECT
m.mount_folder_id AS mount_folder_id,
m.kind AS kind,
m.config AS config,
m.name AS name,
m.owner_id AS owner_id,
m.read_only AS read_only,
f.drive_id AS drive_id,
f.path AS mount_path
FROM storage.external_mounts m
JOIN storage.folders f ON f.id = m.mount_folder_id
WHERE NOT f.is_trashed
"#,
)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::database_error(format!("failed to list external mounts: {e}")))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let drive_id: Option<Uuid> = row
.try_get("drive_id")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?;
let Some(drive_id) = drive_id else {
// A mount root without a drive shouldn't exist post-D0; skip safely.
tracing::warn!(
target: "oxicloud::external_mounts",
"skipping external mount with NULL drive_id"
);
continue;
};
out.push(ExternalMountRecord {
mount_folder_id: row
.try_get("mount_folder_id")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
kind: row
.try_get("kind")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
config: row
.try_get("config")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
name: row
.try_get("name")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
owner_id: row
.try_get("owner_id")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
read_only: row
.try_get("read_only")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
drive_id,
mount_path: row
.try_get("mount_path")
.map_err(|e| DomainError::database_error(format!("external mount row: {e}")))?,
});
}
Ok(out)
}
}
// Gated on `test` too: the module uses the `testcontainers` dev-dependency,
// which is only linked into test targets — a plain `--cfg integration_tests`
// lib build (e.g. clippy's lib pass) must not try to compile it.
#[cfg(all(test, integration_tests))]
mod integration_tests {
use super::*;
use crate::mount_it_support::{fresh_db, insert_mount, provision_folder};
#[tokio::test]
async fn list_all_returns_mount_joined_with_folder() {
let (_c, pool) = fresh_db().await;
let p = provision_folder(&pool, "mountowner", "Media").await;
insert_mount(&pool, &p, "/srv/media").await;
let repo = ExternalMountPgRepository::new(pool.clone());
let mounts = repo.list_all().await.expect("list_all");
assert_eq!(mounts.len(), 1);
let m = &mounts[0];
assert_eq!(m.mount_folder_id, p.mount_folder_id);
assert_eq!(m.kind, "local_fs");
assert_eq!(m.owner_id, p.owner_id);
assert_eq!(m.drive_id, p.drive_id);
assert!(!m.read_only);
// The joined folder path (drive-scoped materialized path) contains the
// mount folder's name.
assert!(
m.mount_path.contains("Media"),
"mount_path was {:?}",
m.mount_path
);
assert_eq!(m.config["path"], "/srv/media");
}
#[tokio::test]
async fn list_all_skips_trashed_mount_folder() {
let (_c, pool) = fresh_db().await;
let p = provision_folder(&pool, "mountowner", "Media").await;
insert_mount(&pool, &p, "/srv/media").await;
// Soft-delete the mount-root folder; the join filters NOT is_trashed.
sqlx::query("UPDATE storage.folders SET is_trashed = true WHERE id = $1")
.bind(p.mount_folder_id)
.execute(pool.as_ref())
.await
.unwrap();
let repo = ExternalMountPgRepository::new(pool.clone());
let mounts = repo.list_all().await.expect("list_all");
assert!(mounts.is_empty());
}
#[tokio::test]
async fn list_all_empty_when_no_mounts() {
let (_c, pool) = fresh_db().await;
let repo = ExternalMountPgRepository::new(pool.clone());
assert!(repo.list_all().await.expect("list_all").is_empty());
}
}
@@ -7,6 +7,7 @@ mod contact_persistence_dto;
mod contact_pg_repository;
mod device_code_pg_repository;
mod drive_pg_repository;
mod external_mount_repository;
mod face_pg_repository;
mod favorites_pg_repository;
pub mod file_metadata_repository;
@@ -36,6 +37,7 @@ pub use contact_persistence_dto::*;
pub use contact_pg_repository::ContactPgRepository;
pub use device_code_pg_repository::DeviceCodePgRepository;
pub use drive_pg_repository::DrivePgRepository;
pub use external_mount_repository::ExternalMountPgRepository;
pub use face_pg_repository::FacePgRepository;
pub use favorites_pg_repository::FavoritesPgRepository;
pub use file_blob_read_repository::FileBlobReadRepository;
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,11 +15,13 @@ pub mod file_system_i18n_service;
pub mod image_transcode_service;
pub mod jwt_service;
pub mod local_blob_backend;
pub mod local_fs_mount_provider;
pub mod login_lockout_service;
pub mod media_metadata_service;
pub mod migration_blob_backend;
pub mod migration_job;
pub mod mock_email_sender;
pub mod mount_provider_factory;
pub mod nextcloud_chunked_upload_service;
pub mod noop_face_analyzer;
pub mod oidc_service;
@@ -0,0 +1,122 @@
//! The single place external mount provider kinds are registered.
//!
//! Adding a new backend (`sftp`, `webdav`, …) is: implement
//! [`ExternalMountProvider`](crate::application::ports::external_mount_ports::ExternalMountProvider)
//! and add one arm to [`DefaultMountProviderFactory::build`]. Nothing else in the
//! router / listing / authz / path-resolution layers changes.
use std::sync::Arc;
use async_trait::async_trait;
use crate::application::ports::external_mount_ports::{
ExternalMountProvider, MountProviderFactory,
};
use crate::domain::errors::DomainError;
use crate::infrastructure::services::local_fs_mount_provider::LocalFsMountProvider;
/// Default factory: knows the built-in provider kinds.
#[derive(Default)]
pub struct DefaultMountProviderFactory;
impl DefaultMountProviderFactory {
/// Construct the factory.
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl MountProviderFactory for DefaultMountProviderFactory {
async fn build(
&self,
kind: &str,
config: &serde_json::Value,
) -> Result<Arc<dyn ExternalMountProvider>, DomainError> {
match kind {
"local_fs" => {
let path = config.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
DomainError::validation_error(
"local_fs mount config requires a string \"path\"",
)
})?;
let read_only = config
.get("read_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let provider = LocalFsMountProvider::new(path, read_only)?;
Ok(Arc::new(provider))
}
other => Err(DomainError::operation_not_supported(
"ExternalMount",
format!("unknown mount provider kind: {other}"),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::errors::{DomainError, ErrorKind};
/// `Arc<dyn ExternalMountProvider>` isn't `Debug`, so `unwrap_err` won't
/// compile — extract the error by matching instead.
fn expect_err(r: Result<Arc<dyn ExternalMountProvider>, DomainError>) -> DomainError {
match r {
Ok(_) => panic!("expected an error"),
Err(e) => e,
}
}
#[tokio::test]
async fn builds_local_fs_provider_from_valid_config() {
let dir = tempfile::tempdir().unwrap();
let factory = DefaultMountProviderFactory::new();
let cfg = serde_json::json!({ "path": dir.path().to_str().unwrap() });
let provider = factory.build("local_fs", &cfg).await.expect("builds");
assert_eq!(provider.kind(), "local_fs");
}
#[tokio::test]
async fn local_fs_honours_read_only_flag() {
let dir = tempfile::tempdir().unwrap();
let factory = DefaultMountProviderFactory::new();
let cfg = serde_json::json!({ "path": dir.path().to_str().unwrap(), "read_only": true });
let provider = factory.build("local_fs", &cfg).await.unwrap();
assert!(provider.capabilities().read_only);
}
#[tokio::test]
async fn unknown_kind_is_unsupported() {
let factory = DefaultMountProviderFactory::new();
let err = expect_err(factory.build("sftp", &serde_json::json!({})).await);
assert_eq!(err.kind, ErrorKind::UnsupportedOperation);
}
#[tokio::test]
async fn local_fs_missing_path_is_validation_error() {
let factory = DefaultMountProviderFactory::new();
let err = expect_err(
factory
.build("local_fs", &serde_json::json!({ "read_only": true }))
.await,
);
assert_eq!(err.kind, ErrorKind::InvalidInput);
}
#[tokio::test]
async fn local_fs_nonexistent_path_errors() {
let factory = DefaultMountProviderFactory::new();
let err = expect_err(
factory
.build(
"local_fs",
&serde_json::json!({ "path": "/no/such/dir/xyz123" }),
)
.await,
);
// Propagated from LocalFsMountProvider::new (canonicalize failure).
assert_eq!(err.kind, ErrorKind::InternalError);
}
}