Files
Oxicloud/src/mount_it_support.rs
T
Bradley Nelson 3c31695579 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).
2026-06-24 23:52:01 -06:00

119 lines
4.2 KiB
Rust

//! Shared testcontainers harness for external-mount integration tests.
//!
//! Compiled only under `#[cfg(all(test, integration_tests))]` (the
//! `testcontainers` dev-dependency is linked into test targets only). Each
//! `fresh_db()` spins up an ephemeral Postgres, applies every migration, and
//! returns a pool — so the DB-backed tests are self-contained and need only
//! docker, not the external `spawn-db.sh` compose harness.
use std::sync::Arc;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::runners::AsyncRunner;
use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt};
use uuid::Uuid;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::infrastructure::repositories::pg::{DrivePgRepository, FolderDbRepository};
/// Postgres image tag — must match production (PG13+): the schema uses
/// `CREATE OR REPLACE TRIGGER` (PG14+) and the `pg_trgm` / `ltree` contrib
/// extensions. The `testcontainers` module default (`11-alpine`) is too old.
pub const PG_IMAGE_TAG: &str = "17-alpine";
/// A provisioned mount: a user with a personal drive, a child folder serving as
/// the mount root, and (when created via [`provision_mount`]) an
/// `external_mounts` row.
pub struct Provisioned {
pub owner_id: Uuid,
pub drive_id: Uuid,
pub mount_folder_id: Uuid,
}
/// Bring up an ephemeral Postgres, apply every migration, return a pool. Keep
/// the returned container handle alive for the test's duration.
pub async fn fresh_db() -> (ContainerAsync<Postgres>, Arc<PgPool>) {
let container = Postgres::default()
.with_tag(PG_IMAGE_TAG)
.start()
.await
.expect("start postgres testcontainer (is docker running?)");
let port = container
.get_host_port_ipv4(5432)
.await
.expect("container port");
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
let pool = PgPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
.expect("connect to ephemeral postgres");
sqlx::migrate!().run(&pool).await.expect("apply migrations");
(container, Arc::new(pool))
}
/// Insert a minimal `user` role account, returning its id.
pub async fn make_user(pool: &PgPool, name: &str) -> Uuid {
sqlx::query_scalar::<_, Uuid>(
"INSERT INTO auth.users (username, email, password_hash, role)
VALUES ($1, $2, 'x', 'user') RETURNING id",
)
.bind(name)
.bind(format!("{name}@example.test"))
.fetch_one(pool)
.await
.expect("insert user")
}
/// Provision user → personal drive → a child folder named `folder_name` that
/// will act as the mount root. Does NOT insert an `external_mounts` row.
pub async fn provision_folder(
pool: &Arc<PgPool>,
user_name: &str,
folder_name: &str,
) -> Provisioned {
let owner_id = make_user(pool, user_name).await;
let drive_repo = DrivePgRepository::new(pool.clone());
let drive = drive_repo
.create_personal_drive_atomic(owner_id, None)
.await
.expect("create personal drive");
let root_folder_id = drive.drive.root_folder_id;
let folder_repo = FolderDbRepository::new(pool.clone());
let folder = folder_repo
.create_folder(
folder_name.to_string(),
Some(root_folder_id.to_string()),
owner_id,
)
.await
.expect("create mount-root folder");
let mount_folder_id = Uuid::parse_str(folder.id()).expect("uuid");
Provisioned {
owner_id,
drive_id: drive.drive.id,
mount_folder_id,
}
}
/// Insert an `external_mounts` row for `mount_folder_id` with a `local_fs`
/// provider pointed at `host_path`.
pub async fn insert_mount(pool: &PgPool, p: &Provisioned, host_path: &str) {
sqlx::query(
"INSERT INTO storage.external_mounts
(mount_folder_id, kind, config, name, owner_id, read_only)
VALUES ($1, 'local_fs', $2, 'Media', $3, false)",
)
.bind(p.mount_folder_id)
.bind(serde_json::json!({ "path": host_path }))
.bind(p.owner_id)
.execute(pool)
.await
.expect("insert external mount");
}