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
Generated
+501 -26
View File
@@ -237,6 +237,22 @@ dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "astral-tokio-tar"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5"
dependencies = [
"filetime",
"futures-core",
"libc",
"portable-atomic",
"rustc-hash",
"tokio",
"tokio-stream",
"xattr",
]
[[package]]
name = "async-channel"
version = "1.9.0"
@@ -1087,6 +1103,83 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bollard"
version = "0.19.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b"
dependencies = [
"async-stream",
"base64 0.22.1",
"bitflags",
"bollard-buildkit-proto",
"bollard-stubs",
"bytes",
"chrono",
"futures-core",
"futures-util",
"hex",
"home",
"http 1.4.0",
"http-body-util",
"hyper 1.9.0",
"hyper-named-pipe",
"hyper-rustls 0.27.9",
"hyper-util",
"hyperlocal",
"log",
"num",
"pin-project-lite",
"rand 0.9.4",
"rustls 0.23.40",
"rustls-native-certs",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"serde_urlencoded",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"tonic",
"tower-service",
"url",
"winapi",
]
[[package]]
name = "bollard-buildkit-proto"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad"
dependencies = [
"prost",
"prost-types",
"tonic",
"tonic-prost",
"ureq",
]
[[package]]
name = "bollard-stubs"
version = "1.49.1-rc.28.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623"
dependencies = [
"base64 0.22.1",
"bollard-buildkit-proto",
"bytes",
"chrono",
"prost",
"serde",
"serde_json",
"serde_repr",
"serde_with",
]
[[package]]
name = "bon"
version = "3.9.2"
@@ -1143,6 +1236,15 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -1280,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
dependencies = [
"heck",
"indexmap",
"indexmap 2.14.0",
"log",
"proc-macro2",
"quote",
@@ -2012,6 +2114,17 @@ dependencies = [
"syn",
]
[[package]]
name = "docker_credential"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d"
dependencies = [
"base64 0.22.1",
"serde",
"serde_json",
]
[[package]]
name = "document-features"
version = "0.2.12"
@@ -2203,6 +2316,17 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "etcetera"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.59.0",
]
[[package]]
name = "euclid"
version = "0.20.14"
@@ -2396,6 +2520,16 @@ dependencies = [
"flate2",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -2745,7 +2879,7 @@ checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
"fnv",
"hashbrown 0.16.1",
"indexmap",
"indexmap 2.14.0",
"stable_deref_trait",
]
@@ -2778,7 +2912,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -2797,7 +2931,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http 1.4.0",
"indexmap",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -2815,6 +2949,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -3068,6 +3208,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-named-pipe"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278"
dependencies = [
"hex",
"hyper 1.9.0",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
"winapi",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
@@ -3100,6 +3255,19 @@ dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.9.0",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -3123,6 +3291,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "hyperlocal"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
dependencies = [
"hex",
"http-body-util",
"hyper 1.9.0",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -3315,6 +3498,17 @@ dependencies = [
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -3691,7 +3885,7 @@ dependencies = [
"encoding_rs",
"flate2",
"getrandom 0.3.4",
"indexmap",
"indexmap 2.14.0",
"itoa",
"log",
"md-5 0.10.6",
@@ -4058,6 +4252,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -4119,6 +4327,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4137,7 +4356,7 @@ checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc"
dependencies = [
"crc32fast",
"hashbrown 0.16.1",
"indexmap",
"indexmap 2.14.0",
"memchr",
]
@@ -4220,6 +4439,7 @@ version = "0.8.0"
dependencies = [
"accept-language",
"aes-gcm",
"arc-swap",
"argon2",
"askama",
"async-compression",
@@ -4283,6 +4503,7 @@ dependencies = [
"sqlx",
"tantivy",
"tempfile",
"testcontainers-modules",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@@ -4353,6 +4574,31 @@ dependencies = [
"windows-link",
]
[[package]]
name = "parse-display"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a"
dependencies = [
"parse-display-derive",
"regex",
"regex-syntax",
]
[[package]]
name = "parse-display-derive"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281"
dependencies = [
"proc-macro2",
"quote",
"regex",
"regex-syntax",
"structmeta",
"syn",
]
[[package]]
name = "password-hash"
version = "0.5.0"
@@ -4419,7 +4665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
"indexmap",
"indexmap 2.14.0",
]
[[package]]
@@ -4700,6 +4946,15 @@ dependencies = [
"syn",
]
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
"prost",
]
[[package]]
name = "pulley-interpreter"
version = "43.0.2"
@@ -5006,6 +5261,26 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "ref-cast"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "regalloc2"
version = "0.15.1"
@@ -5267,6 +5542,15 @@ dependencies = [
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -5329,6 +5613,30 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -5457,6 +5765,17 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -5478,13 +5797,45 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
@@ -5709,7 +6060,7 @@ dependencies = [
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap",
"indexmap 2.14.0",
"log",
"memchr",
"once_cell",
@@ -5823,7 +6174,7 @@ dependencies = [
"chrono",
"crc",
"dotenvy",
"etcetera",
"etcetera 0.8.0",
"futures-channel",
"futures-core",
"futures-util",
@@ -5898,6 +6249,29 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "structmeta"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329"
dependencies = [
"proc-macro2",
"quote",
"structmeta-derive",
"syn",
]
[[package]]
name = "structmeta-derive"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -6139,6 +6513,44 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "testcontainers"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3ac71069f20ecfa60c396316c283fbf35e6833a53dff551a31b5458da05edc"
dependencies = [
"astral-tokio-tar",
"async-trait",
"bollard",
"bytes",
"docker_credential",
"either",
"etcetera 0.10.0",
"futures",
"log",
"memchr",
"parse-display",
"pin-project-lite",
"serde",
"serde_json",
"serde_with",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"ulid",
"url",
]
[[package]]
name = "testcontainers-modules"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1966329d5bb3f89d33602d2db2da971fb839f9297dad16527abf4564e2ae0a6d"
dependencies = [
"testcontainers",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6334,7 +6746,7 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde_core",
"serde_spanned",
"toml_datetime 0.7.5+spec-1.1.0",
@@ -6349,7 +6761,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde_core",
"serde_spanned",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -6382,7 +6794,7 @@ version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
@@ -6403,6 +6815,46 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tonic"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"h2 0.4.13",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.9.0",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.4",
"sync_wrapper",
"tokio",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -6411,9 +6863,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap 2.14.0",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@@ -6586,6 +7041,16 @@ dependencies = [
"syn",
]
[[package]]
name = "ulid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand 0.9.4",
"web-time",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -6725,7 +7190,7 @@ version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde",
"serde_json",
"utoipa-gen",
@@ -6931,7 +7396,7 @@ dependencies = [
"anyhow",
"heck",
"im-rc",
"indexmap",
"indexmap 2.14.0",
"log",
"petgraph",
"serde",
@@ -6980,7 +7445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"indexmap 2.14.0",
"wasm-encoder 0.244.0",
"wasmparser 0.244.0",
]
@@ -7006,7 +7471,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"indexmap 2.14.0",
"semver",
]
@@ -7018,7 +7483,7 @@ checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e"
dependencies = [
"bitflags",
"hashbrown 0.16.1",
"indexmap",
"indexmap 2.14.0",
"semver",
"serde",
]
@@ -7030,7 +7495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c"
dependencies = [
"bitflags",
"indexmap",
"indexmap 2.14.0",
"semver",
]
@@ -7111,7 +7576,7 @@ dependencies = [
"cranelift-entity",
"gimli",
"hashbrown 0.16.1",
"indexmap",
"indexmap 2.14.0",
"log",
"object",
"postcard",
@@ -7298,7 +7763,7 @@ dependencies = [
"anyhow",
"bitflags",
"heck",
"indexmap",
"indexmap 2.14.0",
"wit-parser 0.245.1",
]
@@ -7837,7 +8302,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"indexmap 2.14.0",
"prettyplease",
"syn",
"wasm-metadata",
@@ -7868,7 +8333,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"indexmap 2.14.0",
"log",
"serde",
"serde_derive",
@@ -7887,7 +8352,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"indexmap 2.14.0",
"log",
"semver",
"serde",
@@ -7906,7 +8371,7 @@ dependencies = [
"anyhow",
"hashbrown 0.16.1",
"id-arena",
"indexmap",
"indexmap 2.14.0",
"log",
"semver",
"serde",
@@ -7934,6 +8399,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xmlparser"
version = "0.13.6"
@@ -8065,7 +8540,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"indexmap 2.14.0",
"memchr",
"typed-path",
"zopfli",
+5
View File
@@ -28,6 +28,7 @@ async-trait = "0.1.89"
mime_guess = "2.0.5"
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
thiserror = "2.0.18"
arc-swap = "1.9"
mockall = { version = "0.14.0", optional = true }
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls", "chrono", "uuid", "json", "migrate"] }
@@ -122,6 +123,10 @@ bench = []
[dev-dependencies]
criterion = "0.5"
# Ephemeral service containers for integration tests (gated by
# `--cfg integration_tests`). `testcontainers-modules` re-exports the
# `testcontainers` runner, so the postgres image + runner come from one dep.
testcontainers-modules = { version = "0.13", features = ["postgres"] }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] }
@@ -0,0 +1,47 @@
-- ════════════════════════════════════════════════════════════════════════════
-- External file mounts
-- ════════════════════════════════════════════════════════════════════════════
-- Admin-configured mounts that expose an external backend (raw host filesystem
-- in v1; SFTP/WebDAV/… as future provider `kind`s) as a folder inside a user's
-- drive. The mount ROOT is a normal `storage.folders` row (so it participates in
-- ltree, drive scoping, and ACL grants like any folder); everything BELOW it is
-- virtual — read live from the backend, never stored in `storage.files`.
--
-- This table maps a mount-root folder to its backend. `kind` selects the
-- provider implementation; `config` carries provider-specific connection data
-- (a stable JSONB bag so adding a new provider kind needs no schema change):
-- * local_fs → {"path": "/mnt/share"}
-- * sftp (future) → {"host": "...", "port": 22, "user": "...", "base_path": "..."}
--
-- Mount contents are deliberately a LIMITED, SEPARATE storage type: no blob
-- dedup, no per-file sharing/favorites/trash/search in v1 (deletes are real,
-- permanent backend deletes). See docs / plan for the forward path.
-- ════════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS storage.external_mounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- The mount-root folder. Deleting that folder row removes the mount mapping.
mount_folder_id UUID NOT NULL UNIQUE
REFERENCES storage.folders(id) ON DELETE CASCADE,
-- Provider discriminator (selects the ExternalMountProvider implementation).
kind TEXT NOT NULL DEFAULT 'local_fs',
-- Provider-specific connection config; shape depends on `kind`.
config JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Display name (mirrors the folder name; kept for admin listings).
name TEXT NOT NULL,
-- Admin/user who owns the mount configuration.
owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- When true, the provider refuses all mutations (browse/download only).
read_only BOOLEAN NOT NULL DEFAULT FALSE,
-- Visibility policy. 'owner' = only the owner's drive sees it (v1).
-- Reserved for future 'shared' semantics.
visibility TEXT NOT NULL DEFAULT 'owner',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_external_mounts_folder
ON storage.external_mounts(mount_folder_id);
COMMENT ON TABLE storage.external_mounts IS
'Admin-configured external backends (local_fs/sftp/…) surfaced as a mount-root folder; contents are virtual and read live from the provider.';
@@ -0,0 +1,192 @@
//! External mount provider port — the pluggable backend abstraction for mounts.
//!
//! An [`ExternalMountProvider`] exposes a filesystem-style I/O surface for one
//! mount's backend (raw host fs in v1; SFTP/WebDAV/… later). It is the *lowest
//! common denominator* of browse + CRUD: deliberately small, so new backend
//! `kind`s drop in without touching the router, listing, authz, or path
//! resolution. Rich native features (sharing, trash, search, …) are NOT part of
//! this trait — they compose above it.
//!
//! Each provider instance is **bound to one mount's root location at
//! construction**, so methods take only a provider-owned [`NodeId`], never a host
//! `Path` (an SFTP/WebDAV provider has no local path). The `NodeId` is opaque to
//! the rest of the system (see [`crate::domain::services::external_mount_id`]).
//!
//! The trait returns boxed futures via `#[async_trait]` and takes a boxed write
//! stream, so it is dyn-compatible (`Arc<dyn ExternalMountProvider>`) and a
//! single mount registry can hold providers of different kinds.
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use std::pin::Pin;
use uuid::Uuid;
use crate::application::ports::blob_storage_ports::BlobStream;
use crate::domain::errors::DomainError;
use crate::domain::services::external_mount_id::NodeId;
/// A byte stream handed to [`ExternalMountProvider::write_stream`].
///
/// Boxed (not generic) so the trait stays object-safe. Callers map their body's
/// error type to `std::io::Error` before constructing it.
pub type MountByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
/// One entry returned by [`ExternalMountProvider::list_dir`].
#[derive(Debug, Clone)]
pub struct MountEntry {
/// Final path segment (display name).
pub name: String,
/// Provider-assigned, opaque identity for this entry.
pub node_id: NodeId,
/// Whether the entry is a directory.
pub is_dir: bool,
/// Size in bytes (0 for directories).
pub size: u64,
/// Last-modified time, unix seconds.
pub modified_at: u64,
/// Creation time, unix seconds (falls back to `modified_at` when unavailable).
pub created_at: u64,
}
/// Metadata for a single entry returned by [`ExternalMountProvider::stat`] and
/// by the mutating ops (so the caller learns the new entry's `node_id`).
#[derive(Debug, Clone)]
pub struct MountStat {
/// Provider-assigned, opaque identity for this entry.
pub node_id: NodeId,
/// Whether the entry is a directory.
pub is_dir: bool,
/// Size in bytes (0 for directories).
pub size: u64,
/// Last-modified time, unix seconds.
pub modified_at: u64,
/// Creation time, unix seconds.
pub created_at: u64,
/// MIME type (sniffed from extension for files; `"directory"` for dirs).
pub mime_type: String,
}
/// Static capability flags a provider advertises.
#[derive(Debug, Clone, Copy)]
pub struct MountCaps {
/// Provider can serve byte ranges (HTTP Range / partial reads).
pub supports_range: bool,
/// Provider refuses all mutations.
pub read_only: bool,
/// `node_id`s are stable across renames/moves (e.g. inode / object id).
/// `false` for path-based providers — relevant to the future sharing path.
pub stable_ids: bool,
}
/// Pluggable I/O surface for one mount's backend, bound to its root location.
///
/// Implementations: `LocalFsMountProvider` (v1). All node ids are
/// provider-owned and opaque; the system never parses them.
#[async_trait]
pub trait ExternalMountProvider: Send + Sync + 'static {
/// Provider kind identifier (matches the `kind` column / factory arm).
fn kind(&self) -> &'static str;
/// Static capabilities.
fn capabilities(&self) -> MountCaps;
/// Map an internal path (relative to the mount root) to a `node_id`.
///
/// For path-based providers this is identity (the default). Providers whose
/// identity is not a path override this. Does not assert existence — use
/// [`stat`](Self::stat) for that.
fn resolve_path(&self, path: &str) -> NodeId {
NodeId(path.to_string())
}
/// List the directory identified by `node_id` (root = the provider's bound
/// location, addressed via `resolve_path("")`).
async fn list_dir(&self, node_id: &NodeId) -> Result<Vec<MountEntry>, DomainError>;
/// Stat a single entry.
async fn stat(&self, node_id: &NodeId) -> Result<MountStat, DomainError>;
/// Open a (optionally ranged) read stream over a file's bytes.
///
/// `range` is `(start, end_inclusive_opt)`; `None` reads the whole file.
async fn open_read_stream(
&self,
node_id: &NodeId,
range: Option<(u64, Option<u64>)>,
) -> Result<BlobStream, DomainError>;
/// Create a child directory `name` under `parent`. Returns the new dir's stat.
async fn create_dir(&self, parent: &NodeId, name: &str) -> Result<MountStat, DomainError>;
/// Stream-write a child file `name` under `parent`. Returns the new file's stat.
async fn write_stream(
&self,
parent: &NodeId,
name: &str,
body: MountByteStream,
) -> Result<MountStat, DomainError>;
/// Rename an entry in place (same parent). Returns the renamed entry's stat.
async fn rename(&self, node_id: &NodeId, new_name: &str) -> Result<MountStat, DomainError>;
/// Delete an entry (recursively for directories). Permanent — no trash.
async fn delete(&self, node_id: &NodeId) -> Result<(), DomainError>;
/// Move an entry into `dest_parent`, keeping its name. Returns the new stat.
async fn move_within(
&self,
node_id: &NodeId,
dest_parent: &NodeId,
) -> Result<MountStat, DomainError>;
}
/// A persisted external mount joined with its mount-root folder.
///
/// Returned by [`ExternalMountRepositoryPort::list_all`] to (re)build the
/// in-memory registry.
#[derive(Debug, Clone)]
pub struct ExternalMountRecord {
/// The mount-root folder UUID (also the mount's identity in the registry).
pub mount_folder_id: Uuid,
/// Provider kind (factory discriminator).
pub kind: String,
/// Provider-specific connection config.
pub config: serde_json::Value,
/// Display name.
pub name: String,
/// Owner of the mount configuration.
pub owner_id: Uuid,
/// Whether the mount is read-only.
pub read_only: bool,
/// Drive the mount-root folder belongs to (for path resolution).
pub drive_id: Uuid,
/// Materialized internal path of the mount-root folder (for path resolution).
pub mount_path: String,
}
/// Persistence port for external mount configuration.
#[async_trait]
pub trait ExternalMountRepositoryPort: Send + Sync {
/// Load every (non-trashed) mount joined with its folder, for registry build.
async fn list_all(&self) -> Result<Vec<ExternalMountRecord>, DomainError>;
}
/// Builds [`ExternalMountProvider`]s from a `kind` + `config` pair.
///
/// The single extension point for new backends: adding a provider is
/// implementing the trait plus one arm here.
#[async_trait]
pub trait MountProviderFactory: Send + Sync {
/// Construct a provider for `kind`, parsing its `config` JSON.
///
/// Errors with `UnsupportedOperation` for an unknown kind, or
/// `validation_error` for malformed config.
async fn build(
&self,
kind: &str,
config: &serde_json::Value,
) -> Result<Arc<dyn ExternalMountProvider>, DomainError>;
}
+1
View File
@@ -10,6 +10,7 @@ pub mod compression_ports;
pub mod content_index_ports;
pub mod dedup_ports;
pub mod email_sender;
pub mod external_mount_ports;
pub mod face_ports;
pub mod favorites_ports;
pub mod file_lifecycle;
@@ -100,7 +100,12 @@ mod tests {
None,
authz.clone(),
));
let folder_service = Arc::new(FolderService::new(folder_repo, authz));
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, mount_router));
let _batch_service = BatchOperationService::new(
file_retrieval,
@@ -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
));
}
}
@@ -5,10 +5,14 @@ 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};
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::{
@@ -64,6 +68,22 @@ impl FileRetrievalService {
}
}
/// 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),
}
}
// ── private helpers ──────────────────────────────────────────
/// Read a file's full content through the streaming API into a single
@@ -122,6 +142,50 @@ 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
}
/// Try to transcode image content to WebP and return transcoded variant.
async fn try_transcode(
&self,
+477 -2
View File
@@ -4,10 +4,14 @@ use crate::application::dtos::folder_dto::{
MoveFolderDto, RenameFolderDto,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::external_mount_ports::MountEntry;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::external_mount_router::MountRouter;
use crate::application::services::mount_registry::MountConfig;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
use crate::domain::services::external_mount_id::NodeId;
use crate::domain::services::path_service::{StoragePath, validate_storage_name};
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
@@ -18,17 +22,31 @@ use uuid::Uuid;
pub struct FolderService {
folder_storage: Arc<FolderDbRepository>,
authz: Arc<PgAclEngine>,
/// External-mount classifier. Lets folder operations branch a mount-root or
/// `ext:` id onto the provider instead of the PostgreSQL repositories.
mount_router: Arc<MountRouter>,
}
impl FolderService {
/// Creates a new folder service
pub fn new(folder_storage: Arc<FolderDbRepository>, authz: Arc<PgAclEngine>) -> Self {
pub fn new(
folder_storage: Arc<FolderDbRepository>,
authz: Arc<PgAclEngine>,
mount_router: Arc<MountRouter>,
) -> Self {
Self {
folder_storage,
authz,
mount_router,
}
}
/// Borrow the external-mount classifier (handlers branch on this before
/// treating an id as a native UUID).
pub fn mount_router(&self) -> &MountRouter {
&self.mount_router
}
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
/// query instead of one per id. Like `get_folder` it performs no
/// per-folder authorization — both current callers (ACL grant listing,
@@ -615,6 +633,125 @@ impl FolderService {
Ok((rows, next_cursor))
}
/// List one directory inside an external mount (the mount root when
/// `node_id` is empty, or a nested virtual folder otherwise).
///
/// Authorization collapses onto the mount-root folder: a caller who may
/// `Read` the mount root may browse everything inside it. The provider
/// reads the live backend; entries are sorted in memory and paginated with
/// a name-keyset cursor (directories are bounded, see provider cap).
///
/// Returns the page of raw [`MountEntry`]s plus an encoded next cursor; the
/// handler maps each entry to a `FolderResourceItemDto` with a synthetic
/// `ext:` id.
pub async fn list_mount_dir_with_perms(
&self,
cfg: &MountConfig,
node_id: &NodeId,
caller_id: Uuid,
opts: ListResourcesOptions<'_>,
) -> Result<(Vec<MountEntry>, Option<String>), DomainError> {
// AuthZ — everything in the mount is gated by the mount-root folder.
self.authz
.require(
Subject::User(caller_id),
Permission::Read,
Resource::Folder(cfg.mount_id),
)
.await?;
let entries = cfg.provider.list_dir(node_id).await?;
let cursor_name = opts.cursor.as_ref().and_then(|c| c.sort_str.as_deref());
Ok(paginate_mount_entries(
entries,
opts.kinds,
opts.order_by,
opts.reverse,
opts.limit,
cursor_name,
))
}
}
/// Filter, sort, and page a directory's worth of mount entries, returning the
/// page plus an encoded next cursor. Pure (no I/O / authz) so it can be tested
/// exhaustively.
///
/// The cursor is a **name keyset**: names are unique within a directory, so the
/// last emitted name is a stable resume key under any sort dimension. Resume is
/// best-effort — if the cursor's entry was deleted out-of-band the page restarts
/// from the top (documented; avoids an infinite loop).
fn paginate_mount_entries(
mut entries: Vec<MountEntry>,
kinds: Option<&[ResourceKind]>,
order_by: &str,
reverse: bool,
limit: usize,
cursor_name: Option<&str>,
) -> (Vec<MountEntry>, Option<String>) {
if let Some(kinds) = kinds {
let want_files = kinds.contains(&ResourceKind::File);
let want_folders = kinds.contains(&ResourceKind::Folder);
entries.retain(|e| if e.is_dir { want_folders } else { want_files });
}
sort_mount_entries(&mut entries, order_by, reverse);
let start = match cursor_name {
Some(name) => entries
.iter()
.position(|e| name.eq_ignore_ascii_case(&e.name))
.map(|i| i + 1)
.unwrap_or(0),
None => 0,
};
let has_more = entries.len() > start + limit;
let page: Vec<MountEntry> = entries.into_iter().skip(start).take(limit).collect();
let next_cursor = if has_more {
page.last().map(|last| {
FolderResourceCursor {
order_by: order_by.to_owned(),
resource_id: Uuid::nil(),
sort_str: Some(last.name.clone()),
sort_int: None,
sort_ts: None,
reverse,
}
.encode()
})
} else {
None
};
(page, next_cursor)
}
/// Sort mount entries in place. Folders sort before files for the `name`/`type`
/// dimensions; otherwise by the requested key with name as the tie-breaker.
/// `reverse` flips the final order.
fn sort_mount_entries(entries: &mut [MountEntry], order_by: &str, reverse: bool) {
use std::cmp::Ordering;
let name_key = |e: &MountEntry| e.name.to_lowercase();
entries.sort_by(|a, b| {
let primary = match order_by {
"modified_at" => a.modified_at.cmp(&b.modified_at),
"created_at" => a.created_at.cmp(&b.created_at),
"size" => a.size.cmp(&b.size),
// "name" / "type" / anything else: folders first, then by name.
_ => b.is_dir.cmp(&a.is_dir),
};
let ord = primary.then_with(|| name_key(a).cmp(&name_key(b)));
if ord == Ordering::Equal {
Ordering::Equal
} else if reverse {
ord.reverse()
} else {
ord
}
});
}
/// Build the next-page cursor from the last row of the current page.
@@ -842,3 +979,341 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook {
Ok(())
}
}
#[cfg(test)]
mod mount_listing_tests {
use super::{paginate_mount_entries, sort_mount_entries};
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::folder_dto::FolderResourceCursor;
use crate::application::ports::external_mount_ports::MountEntry;
use crate::domain::services::authorization::ResourceKind;
use crate::domain::services::external_mount_id::NodeId;
fn entry(name: &str, is_dir: bool, size: u64, modified: u64) -> MountEntry {
MountEntry {
name: name.to_string(),
node_id: NodeId(name.to_string()),
is_dir,
size,
modified_at: modified,
created_at: modified,
}
}
fn names(entries: &[MountEntry]) -> Vec<String> {
entries.iter().map(|e| e.name.clone()).collect()
}
#[test]
fn sorts_folders_first_then_name_case_insensitive() {
let mut e = vec![
entry("Banana.txt", false, 1, 1),
entry("apple", true, 0, 1),
entry("Cherry", true, 0, 1),
entry("almond.txt", false, 1, 1),
];
sort_mount_entries(&mut e, "name", false);
assert_eq!(names(&e), ["apple", "Cherry", "almond.txt", "Banana.txt"]);
}
#[test]
fn reverse_flips_order() {
let mut e = vec![
entry("a", false, 1, 1),
entry("b", false, 1, 1),
entry("d", true, 0, 1),
];
sort_mount_entries(&mut e, "name", true);
// folders-first then name, reversed.
assert_eq!(names(&e), ["b", "a", "d"]);
}
#[test]
fn sorts_by_size_modified_created() {
let mut by_size = vec![
entry("big", false, 100, 1),
entry("small", false, 1, 1),
entry("mid", false, 50, 1),
];
sort_mount_entries(&mut by_size, "size", false);
assert_eq!(names(&by_size), ["small", "mid", "big"]);
let mut by_mtime = vec![
entry("new", false, 1, 300),
entry("old", false, 1, 100),
entry("mid", false, 1, 200),
];
sort_mount_entries(&mut by_mtime, "modified_at", false);
assert_eq!(names(&by_mtime), ["old", "mid", "new"]);
let mut by_ctime = vec![entry("z", false, 1, 9), entry("a", false, 1, 5)];
sort_mount_entries(&mut by_ctime, "created_at", false);
assert_eq!(names(&by_ctime), ["a", "z"]);
}
#[test]
fn filters_by_kind() {
let make = || vec![entry("dir", true, 0, 1), entry("file.txt", false, 1, 1)];
let (files_only, _) =
paginate_mount_entries(make(), Some(&[ResourceKind::File]), "name", false, 50, None);
assert_eq!(names(&files_only), ["file.txt"]);
let (folders_only, _) = paginate_mount_entries(
make(),
Some(&[ResourceKind::Folder]),
"name",
false,
50,
None,
);
assert_eq!(names(&folders_only), ["dir"]);
let (both, _) = paginate_mount_entries(
make(),
Some(&[ResourceKind::File, ResourceKind::Folder]),
"name",
false,
50,
None,
);
assert_eq!(both.len(), 2);
}
#[test]
fn paginates_with_name_keyset_cursor() {
let all = || {
vec![
entry("a", false, 1, 1),
entry("b", false, 1, 1),
entry("c", false, 1, 1),
entry("d", false, 1, 1),
entry("e", false, 1, 1),
]
};
// Page 1: limit 2 → [a, b], cursor present.
let (p1, c1) = paginate_mount_entries(all(), None, "name", false, 2, None);
assert_eq!(names(&p1), ["a", "b"]);
let c1 = c1.expect("cursor after first page");
let decoded = FolderResourceCursor::decode(&c1).expect("decodes");
assert_eq!(decoded.sort_str.as_deref(), Some("b"));
assert!(!decoded.reverse);
// Page 2: resume after "b" → [c, d], cursor present.
let (p2, c2) = paginate_mount_entries(all(), None, "name", false, 2, Some("b"));
assert_eq!(names(&p2), ["c", "d"]);
assert!(c2.is_some());
// Page 3: resume after "d" → [e], no further cursor.
let (p3, c3) = paginate_mount_entries(all(), None, "name", false, 2, Some("d"));
assert_eq!(names(&p3), ["e"]);
assert!(c3.is_none());
}
#[test]
fn no_cursor_when_page_is_last() {
let e = vec![entry("a", false, 1, 1), entry("b", false, 1, 1)];
let (page, cursor) = paginate_mount_entries(e, None, "name", false, 50, None);
assert_eq!(page.len(), 2);
assert!(cursor.is_none());
}
#[test]
fn deleted_cursor_entry_restarts_best_effort() {
// Cursor names "zzz" which is not present → start from the top.
let e = vec![entry("a", false, 1, 1), entry("b", false, 1, 1)];
let (page, _) = paginate_mount_entries(e, None, "name", false, 50, Some("zzz"));
assert_eq!(names(&page), ["a", "b"]);
}
#[test]
fn empty_directory_yields_empty_page() {
let (page, cursor) = paginate_mount_entries(vec![], None, "name", false, 50, None);
assert!(page.is_empty());
assert!(cursor.is_none());
}
#[test]
fn limit_larger_than_len_returns_all_without_cursor() {
let e = vec![entry("a", false, 1, 1), entry("b", false, 1, 1)];
let (page, cursor) = paginate_mount_entries(e, None, "name", false, 100, None);
assert_eq!(page.len(), 2);
assert!(cursor.is_none());
}
#[test]
fn kind_filter_excluding_all_yields_empty() {
let e = vec![entry("only_dir", true, 0, 1)];
let (page, cursor) =
paginate_mount_entries(e, Some(&[ResourceKind::File]), "name", false, 50, None);
assert!(page.is_empty());
assert!(cursor.is_none());
}
#[test]
fn cursor_preserves_reverse_flag() {
let e = vec![
entry("a", false, 1, 1),
entry("b", false, 1, 1),
entry("c", false, 1, 1),
];
let (_p, c) = paginate_mount_entries(e, None, "name", true, 1, None);
let decoded = FolderResourceCursor::decode(&c.unwrap()).unwrap();
assert!(decoded.reverse);
assert_eq!(decoded.order_by, "name");
}
}
#[cfg(all(test, integration_tests))]
mod mount_authz_integration {
use super::*;
use crate::application::dtos::folder_dto::ListResourcesOptions;
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
use crate::application::services::file_retrieval_service::FileRetrievalService;
use crate::application::services::mount_registry::MountRegistry;
use crate::domain::services::external_mount_id::{NodeId, encode_child_id};
use crate::infrastructure::repositories::pg::{
ExternalMountPgRepository, FileBlobReadRepository, SubjectGroupPgRepository,
};
use crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory;
use crate::mount_it_support::{fresh_db, insert_mount, make_user, provision_folder};
use std::sync::Arc;
fn opts<'a>() -> ListResourcesOptions<'a> {
ListResourcesOptions {
limit: 50,
cursor: None,
order_by: "name",
kinds: None,
reverse: false,
}
}
/// Build a real PgAclEngine over the live pool. The folder-ancestry cascade
/// uses the engine's own pool; the file repo is a stub (not exercised by
/// folder checks).
fn acl(pool: &Arc<sqlx::PgPool>) -> Arc<PgAclEngine> {
Arc::new(PgAclEngine::new(
pool.clone(),
Arc::new(FolderDbRepository::new(pool.clone())),
Arc::new(FileBlobReadRepository::new_stub()),
Arc::new(SubjectGroupPgRepository::new(pool.clone())),
))
}
/// Full read path: owner can list a mount's live contents; a stranger with
/// no grant is denied. Exercises the REAL authorization cascade
/// (`authz.require(Resource::Folder(mount_id))`) over ltree ancestry.
#[tokio::test]
async fn owner_lists_mount_contents_stranger_denied() {
let (_c, pool) = fresh_db().await;
// Real host directory the mount points at.
let host = tempfile::tempdir().unwrap();
std::fs::write(host.path().join("a.txt"), b"hello").unwrap();
std::fs::create_dir(host.path().join("sub")).unwrap();
let p = provision_folder(&pool, "owner", "Media").await;
insert_mount(&pool, &p, host.path().to_str().unwrap()).await;
// Build the registry from the DB (also exercises reload + provider build).
let registry = Arc::new(MountRegistry::empty());
registry
.reload(
&ExternalMountPgRepository::new(pool.clone()),
&DefaultMountProviderFactory::new(),
)
.await;
let router = Arc::new(MountRouter::new(registry.clone()));
let folder_service = FolderService::new(
Arc::new(FolderDbRepository::new(pool.clone())),
acl(&pool),
router.clone(),
);
let cfg = registry.get(&p.mount_folder_id).expect("mount registered");
// The mount root UUID classifies as a MountRoot.
assert!(matches!(
router.classify(&p.mount_folder_id.to_string()),
ResolvedId::MountRoot { .. }
));
// Owner lists the live directory contents.
let (entries, _cursor) = folder_service
.list_mount_dir_with_perms(&cfg, &NodeId::default(), p.owner_id, opts())
.await
.expect("owner may list");
let mut names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect();
names.sort();
assert_eq!(names, ["a.txt", "sub"]);
// A stranger with no grant on the mount-root folder is denied
// (NotFound — anti-enumeration).
let stranger = make_user(&pool, "stranger").await;
let err = folder_service
.list_mount_dir_with_perms(&cfg, &NodeId::default(), stranger, opts())
.await
.expect_err("stranger must be denied");
assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound);
}
/// Download path authz: owner can stat/open a mount file; stranger denied.
#[tokio::test]
async fn owner_reads_mount_file_stranger_denied() {
let (_c, pool) = fresh_db().await;
let host = tempfile::tempdir().unwrap();
std::fs::write(host.path().join("doc.txt"), b"payload").unwrap();
let p = provision_folder(&pool, "owner", "Media").await;
insert_mount(&pool, &p, host.path().to_str().unwrap()).await;
let registry = Arc::new(MountRegistry::empty());
registry
.reload(
&ExternalMountPgRepository::new(pool.clone()),
&DefaultMountProviderFactory::new(),
)
.await;
let cfg = registry.get(&p.mount_folder_id).expect("registered");
let retrieval = FileRetrievalService::new_with_authz_for_test(
Arc::new(FileBlobReadRepository::new_stub()),
acl(&pool),
);
let node = NodeId::from("doc.txt");
// Owner: stat succeeds with the real size.
let stat = retrieval
.stat_mount_file_with_perms(&cfg, &node, p.owner_id)
.await
.expect("owner may stat");
assert_eq!(stat.size, 7);
assert!(!stat.is_dir);
// Owner: open succeeds (smoke — stream is consumed elsewhere).
assert!(
retrieval
.open_mount_file_with_perms(&cfg, &node, p.owner_id, None)
.await
.is_ok()
);
// The synthetic id for this file round-trips through the router.
let ext_id = encode_child_id(p.mount_folder_id, "doc.txt");
assert!(matches!(
MountRouter::new(registry.clone()).classify(&ext_id),
ResolvedId::MountChild { .. }
));
// Stranger: denied.
let stranger = make_user(&pool, "stranger").await;
let err = retrieval
.stat_mount_file_with_perms(&cfg, &node, stranger)
.await
.expect_err("stranger denied");
assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound);
}
}
+2
View File
@@ -9,6 +9,7 @@ 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 favorites_service;
pub mod file_lifecycle_service;
pub mod file_management_service;
@@ -18,6 +19,7 @@ pub mod file_use_case_factory;
pub mod folder_service;
pub mod i18n_application_service;
pub mod magic_link_invite_service;
pub mod mount_registry;
pub mod music_service;
pub mod nextcloud_file_id_service;
pub mod nextcloud_login_flow_service;
+374
View File
@@ -0,0 +1,374 @@
//! 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();
// 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;
}
};
by_path.insert((rec.drive_id, rec.mount_path.clone()), 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");
}
}
+14
View File
@@ -906,6 +906,12 @@ pub struct FeaturesConfig {
/// thumbnail through the same WebP pipeline as photos; otherwise videos have
/// no thumbnail. Env: `OXICLOUD_ENABLE_VIDEO_THUMBNAILS`.
pub enable_video_thumbnails: bool,
/// Expose admin-configured external filesystem mounts (raw host fs, …) as
/// folders inside a user's drive. Contents are read live from the backend
/// and are a deliberately limited, separate storage type (no dedup/sharing/
/// trash/search). OFF by default — opt-in per deployment.
/// Env: `OXICLOUD_ENABLE_EXTERNAL_MOUNTS`.
pub enable_external_mounts: bool,
}
impl Default for FeaturesConfig {
@@ -921,6 +927,7 @@ impl Default for FeaturesConfig {
enable_faces: false, // People/faces (biometric) — opt-in, off by default
expose_system_users: true, // Expose OxiCloud users as address book by default
enable_video_thumbnails: true, // Video thumbs via ffmpeg (if detected)
enable_external_mounts: false, // External mounts — opt-in, off by default
}
}
}
@@ -1488,6 +1495,13 @@ impl AppConfig {
config.features.enable_faces = val;
}
if let Ok(enable_external_mounts) =
env::var("OXICLOUD_ENABLE_EXTERNAL_MOUNTS").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_external_mounts
{
config.features.enable_external_mounts = val;
}
// Faces (People) ONNX runtime + models — operator-provided at runtime.
if let Ok(v) = env::var("OXICLOUD_FACES_ORT_DYLIB").or_else(|_| env::var("ORT_DYLIB_PATH"))
&& !v.is_empty()
+32
View File
@@ -518,11 +518,13 @@ impl AppServiceFactory {
plugin_dispatch: Option<
Arc<dyn crate::application::ports::plugin_ports::PluginDispatchPort>,
>,
mount_router: Arc<crate::application::services::external_mount_router::MountRouter>,
) -> ApplicationServices {
// Main services
let folder_service = Arc::new(FolderService::new(
repos.folder_repository.clone(),
authz.clone(),
mount_router,
));
// Built before the upload/management services so the plugin lifecycle
@@ -1160,6 +1162,27 @@ impl AppServiceFactory {
let (plugin_dispatch, plugin_management) = self.create_plugin_ports();
// 4. Application services (with trash + authz already wired)
// External mount registry + router. Built before the application
// services because `FolderService` holds the router to branch listing
// onto the provider. The router is always present (an empty registry is
// a cheap no-op); when the feature is enabled we load the configured
// mounts and build their providers up front. The registry is
// interior-mutable (arc-swap), so reloading here is visible to every
// holder of the shared router.
let mount_registry =
Arc::new(crate::application::services::mount_registry::MountRegistry::empty());
if self.config.features.enable_external_mounts {
let repo = crate::infrastructure::repositories::pg::ExternalMountPgRepository::new(
pool.clone(),
);
let factory =
crate::infrastructure::services::mount_provider_factory::DefaultMountProviderFactory::new();
mount_registry.reload(&repo, &factory).await;
}
let mount_router = Arc::new(
crate::application::services::external_mount_router::MountRouter::new(mount_registry),
);
let mut apps = self.create_application_services(
&core,
&repos,
@@ -1169,6 +1192,7 @@ impl AppServiceFactory {
&storage_usage,
content_index.as_ref().map(|(idx, _)| idx.clone()),
plugin_dispatch.clone(),
mount_router.clone(),
);
// 5. Share service
@@ -1441,6 +1465,7 @@ impl AppServiceFactory {
locale_registry: self.locale_registry.clone(),
db_pool: Some(pool.clone()),
maintenance_pool: Some(maintenance_pool),
mount_router,
auth_service: auth_services,
nextcloud: nextcloud_services,
admin_settings_service: None,
@@ -1894,6 +1919,13 @@ pub struct AppState {
pub db_pool: Option<Arc<PgPool>>,
/// Isolated pool for background / batch operations.
pub maintenance_pool: Option<Arc<PgPool>>,
/// External-mount classifier + registry. Always present; an empty registry
/// (feature disabled or no mounts configured) makes `classify` a cheap no-op
/// that routes every id to native handling. Handlers consult this before
/// parsing an id as a UUID, then call the matching service-layer mount
/// method (which still owns the authorization check).
pub mount_router:
Arc<crate::application::services::external_mount_router::MountRouter>,
pub auth_service: Option<AuthServices>,
pub nextcloud: Option<NextcloudServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
+258
View File
@@ -0,0 +1,258 @@
//! External mount identifiers (domain value objects).
//!
//! Files and folders *below* an external mount root have no database row. They
//! are addressed by a synthetic id that wraps a provider-owned node identity:
//!
//! ```text
//! ext:<mount_id>:<base64url(node_id)>
//! ```
//!
//! * `<mount_id>` is the mount-root folder's UUID (simple form, no hyphens) — the
//! only part the system interprets, used to find the provider in the registry.
//! * `<node_id>` is **assigned and owned by the provider** and is **opaque** to the
//! rest of the system. Most providers use the entry's path (`local_fs`, `sftp`,
//! `webdav`); a provider with a stronger stable handle (inode, object id, href)
//! may use that. It is base64url-encoded (no padding) so it survives URLs and
//! WebDAV hrefs and never collides with the `:` / `/` separators.
//!
//! The system NEVER parses or validates a `node_id` — it only splits the envelope
//! and hands the decoded bytes back to the provider verbatim.
use std::fmt;
use std::str::FromStr;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use uuid::Uuid;
/// The `ext:` scheme prefix that marks a synthetic external-mount id.
pub const EXTERNAL_ID_PREFIX: &str = "ext:";
/// A provider-owned, opaque node identity for one entry inside a mount.
///
/// The system treats this as opaque bytes. For path-based providers it is the
/// POSIX path relative to the mount root (no leading `/`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct NodeId(pub String);
impl NodeId {
/// Borrow the inner string.
pub fn as_str(&self) -> &str {
&self.0
}
/// Consume into the inner string.
pub fn into_string(self) -> String {
self.0
}
}
impl From<String> for NodeId {
fn from(s: String) -> Self {
NodeId(s)
}
}
impl From<&str> for NodeId {
fn from(s: &str) -> Self {
NodeId(s.to_owned())
}
}
impl fmt::Display for NodeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// The decoded parts of a synthetic external-mount child id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountChildId {
/// The mount-root folder UUID (envelope; system-interpreted).
pub mount_id: Uuid,
/// The provider-owned node identity (opaque payload).
pub node_id: NodeId,
}
impl MountChildId {
/// Build a child id from a mount root and a provider node id.
pub fn new(mount_id: Uuid, node_id: impl Into<NodeId>) -> Self {
Self {
mount_id,
node_id: node_id.into(),
}
}
}
/// Renders as `ext:<mount_id>:<base64url(node_id)>`.
impl fmt::Display for MountChildId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{EXTERNAL_ID_PREFIX}{}:{}",
self.mount_id.simple(),
URL_SAFE_NO_PAD.encode(self.node_id.0.as_bytes())
)
}
}
/// Error returned when a string is not a well-formed external-mount child id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseMountChildIdError;
impl fmt::Display for ParseMountChildIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("not a valid external mount id (expected ext:<mount_id>:<token>)")
}
}
impl std::error::Error for ParseMountChildIdError {}
impl FromStr for MountChildId {
type Err = ParseMountChildIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// ext:<mount_id>:<token> — split into exactly three logical parts.
let rest = s
.strip_prefix(EXTERNAL_ID_PREFIX)
.ok_or(ParseMountChildIdError)?;
let (mount_part, token) = rest.split_once(':').ok_or(ParseMountChildIdError)?;
let mount_id = Uuid::parse_str(mount_part).map_err(|_| ParseMountChildIdError)?;
let bytes = URL_SAFE_NO_PAD
.decode(token)
.map_err(|_| ParseMountChildIdError)?;
let node = String::from_utf8(bytes).map_err(|_| ParseMountChildIdError)?;
Ok(MountChildId {
mount_id,
node_id: NodeId(node),
})
}
}
/// Cheap check: does this id use the external-mount scheme?
///
/// Used at the top of service methods to route `ext:` ids away from
/// `Uuid::parse_str` and the PostgreSQL repositories.
pub fn is_external_id(id: &str) -> bool {
id.starts_with(EXTERNAL_ID_PREFIX)
}
/// Encode a child id string from its parts.
pub fn encode_child_id(mount_id: Uuid, node_id: impl Into<NodeId>) -> String {
MountChildId::new(mount_id, node_id).to_string()
}
/// Parse a child id string into its parts, or `None` when not `ext:`-shaped.
pub fn parse_child_id(id: &str) -> Option<MountChildId> {
id.parse().ok()
}
/// ETag for a virtual file (no blob hash): `ext-{size:x}-{modified_at}`.
///
/// Combines size and mtime so it changes on any content edit without reading
/// the file. Mirrors the native `{blob_hash[..16]}-{modified_at}` shape closely
/// enough for conditional requests.
pub fn virtual_file_etag(size: u64, modified_at: u64) -> String {
format!("ext-{size:x}-{modified_at}")
}
/// ETag for a virtual folder: `ext-{modified_at}` (directory mtime).
pub fn virtual_folder_etag(modified_at: u64) -> String {
format!("ext-{modified_at}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_simple_path() {
let mount = Uuid::new_v4();
let id = encode_child_id(mount, "docs/report.txt");
assert!(is_external_id(&id));
let parsed = parse_child_id(&id).expect("parse");
assert_eq!(parsed.mount_id, mount);
assert_eq!(parsed.node_id.as_str(), "docs/report.txt");
}
#[test]
fn round_trips_via_fromstr_display() {
let mount = Uuid::new_v4();
let child = MountChildId::new(mount, "a/b/c.bin");
let rendered = child.to_string();
let parsed: MountChildId = rendered.parse().expect("parse");
assert_eq!(child, parsed);
}
#[test]
fn token_survives_separators_and_unicode() {
// node ids containing ':' '/' and non-ascii must survive the envelope.
let mount = Uuid::new_v4();
let tricky = "weird: name/with:colons/café.txt";
let id = encode_child_id(mount, tricky);
// The encoded form must not be ambiguous to the splitter: exactly two
// colons (the `ext:` scheme and the `<mount_id>:` separator); the
// base64url token never contains `:` or `/`.
assert_eq!(id.matches(':').count(), 2, "only scheme + mount separators");
let parsed = parse_child_id(&id).expect("parse");
assert_eq!(parsed.node_id.as_str(), tricky);
}
#[test]
fn rejects_non_external_ids() {
assert!(parse_child_id("not-an-ext-id").is_none());
assert!(parse_child_id(&Uuid::new_v4().to_string()).is_none());
assert!(parse_child_id("ext:not-a-uuid:dG9rZW4").is_none());
assert!(!is_external_id(&Uuid::new_v4().to_string()));
}
#[test]
fn rejects_malformed_envelopes() {
// Missing the second colon (no token separator).
assert!(parse_child_id("ext:abc").is_none());
// `ext:` with a valid uuid but a non-base64url token.
let u = Uuid::new_v4().simple().to_string();
assert!(parse_child_id(&format!("ext:{u}:!!!not-base64!!!")).is_none());
// Empty string / bare scheme.
assert!(parse_child_id("").is_none());
assert!(parse_child_id("ext:").is_none());
// is_external_id is a pure prefix check.
assert!(is_external_id("ext:anything"));
assert!(!is_external_id("EXT:upper"));
}
#[test]
fn round_trips_empty_node_id() {
// The mount root's own node id is empty; it must still round-trip
// (e.g. if ever encoded), encoding to a token-less-but-present form.
let mount = Uuid::new_v4();
let id = encode_child_id(mount, "");
let parsed = parse_child_id(&id).expect("parse empty node");
assert_eq!(parsed.mount_id, mount);
assert_eq!(parsed.node_id.as_str(), "");
}
#[test]
fn round_trips_long_and_nested_paths() {
let mount = Uuid::new_v4();
let deep = "a/".repeat(64) + "leaf.bin";
let id = encode_child_id(mount, deep.clone());
assert_eq!(parse_child_id(&id).unwrap().node_id.as_str(), deep);
}
#[test]
fn node_id_conversions() {
assert_eq!(NodeId::from("x").as_str(), "x");
assert_eq!(NodeId::from(String::from("y")).into_string(), "y");
assert_eq!(NodeId::default().as_str(), "");
}
#[test]
fn etags_change_with_inputs() {
assert_ne!(virtual_file_etag(10, 100), virtual_file_etag(11, 100));
assert_ne!(virtual_file_etag(10, 100), virtual_file_etag(10, 101));
assert_ne!(virtual_folder_etag(100), virtual_folder_etag(101));
// Format is stable and documented.
assert_eq!(virtual_file_etag(255, 16), "ext-ff-16");
assert_eq!(virtual_folder_etag(42), "ext-42");
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod authorization;
pub mod email_normalize;
pub mod external_mount_id;
pub mod i18n_service;
pub mod path_service;
@@ -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);
}
}
+161
View File
@@ -11,13 +11,18 @@ use serde::Deserialize;
use std::collections::HashMap;
use utoipa::ToSchema;
use crate::application::ports::external_mount_ports::MountStat;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
};
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use crate::application::ports::thumbnail_ports::ThumbnailPort;
use crate::application::ports::{file_ports::OptimizedFileContent, folder_ports::FolderUseCase};
use crate::application::services::external_mount_router::ResolvedId;
use crate::application::services::mount_registry::MountConfig;
use crate::common::di::AppState;
use crate::domain::errors::DomainError;
use crate::domain::services::external_mount_id::{NodeId, virtual_file_etag};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
use crate::interfaces::range_requests::not_modified_response;
@@ -631,6 +636,22 @@ impl FileHandler {
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> impl IntoResponse {
// External mount: download a file living on the provider's backend.
// (A mount-root UUID is a folder and is not downloadable — it falls
// through and 404s as a non-file.)
if let ResolvedId::MountChild { cfg, node_id } = state.mount_router.classify(&id) {
return Self::download_mount_file(
&state,
&cfg,
&node_id,
&id,
auth_user.id,
&params,
&headers,
)
.await;
}
let retrieval = &state.applications.file_retrieval_service;
// ── Get file metadata (ownership-scoped) ────────────────────────
@@ -769,6 +790,146 @@ impl FileHandler {
}
}
/// Download a file living inside an external mount: stat via the provider
/// (authorized against the mount root), then serve metadata / 304 / Range /
/// full stream straight from the backend. No blob cache, dedup, or WebP
/// transcode — mount content is served as-is.
#[allow(clippy::too_many_arguments)]
pub(super) async fn download_mount_file(
state: &AppState,
cfg: &MountConfig,
node_id: &NodeId,
id: &str,
caller_id: uuid::Uuid,
params: &HashMap<String, String>,
headers: &HeaderMap,
) -> axum::response::Response {
let retrieval = &state.applications.file_retrieval_service;
let stat: MountStat = match retrieval
.stat_mount_file_with_perms(cfg, node_id, caller_id)
.await
{
Ok(s) => s,
Err(err) => return AppError::from(err).into_response(),
};
if stat.is_dir {
// Directories are not downloadable through this endpoint.
return AppError::from(DomainError::not_found("File", id)).into_response();
}
let name = node_id
.as_str()
.rsplit('/')
.next()
.unwrap_or_else(|| node_id.as_str());
// ── Metadata-only request ────────────────────────────────────
if params
.get("metadata")
.is_some_and(|v| v == "true" || v == "1")
{
return (
StatusCode::OK,
Json(serde_json::json!({
"id": id,
"name": name,
"size": stat.size,
"mime_type": stat.mime_type,
"modified_at": stat.modified_at,
})),
)
.into_response();
}
let etag = format!("\"{}\"", virtual_file_etag(stat.size, stat.modified_at));
if let Some(resp) = not_modified_response(headers, &etag) {
return resp.into_response();
}
// ── Range Requests ───────────────────────────────────────────
if let Some(range_header) = headers.get(header::RANGE)
&& let Ok(range_str) = range_header.to_str()
&& let Ok(ranges) = parse_range_header(range_str)
{
match ranges.validate(stat.size) {
Ok(valid_ranges) => {
if let Some(range) = valid_ranges.first() {
let start = *range.start();
let end = *range.end();
let range_length = end - start + 1;
let disposition = Self::content_disposition(name, &stat.mime_type, params);
match retrieval
.open_mount_file_with_perms(
cfg,
node_id,
caller_id,
Some((start, Some(end))),
)
.await
{
Ok(stream) => {
return Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &stat.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, range_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, stat.size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.body(Body::from_stream(stream))
.unwrap()
.into_response();
}
Err(err) => {
tracing::error!("Error creating mount range stream: {}", err);
// fall through to full download
}
}
}
}
Err(_) => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", stat.size))
.body(Body::empty())
.unwrap()
.into_response();
}
}
}
// ── Normal download ──────────────────────────────────────────
let disposition = Self::content_disposition(name, &stat.mime_type, params);
match retrieval
.open_mount_file_with_perms(cfg, node_id, caller_id, None)
.await
{
Ok(stream) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &stat.mime_type)
.header(header::CONTENT_DISPOSITION, &disposition)
.header(header::CONTENT_LENGTH, stat.size)
.header(header::ETAG, &etag)
.header(
header::CACHE_CONTROL,
"private, max-age=3600, must-revalidate",
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(stream))
.unwrap()
.into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
// ═══════════════════════════════════════════════════════════════════════
// LIST
// ═══════════════════════════════════════════════════════════════════════
@@ -17,11 +17,17 @@ use crate::application::dtos::folder_dto::{
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::external_mount_ports::MountEntry;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::external_mount_router::ResolvedId;
use crate::application::services::folder_service::FolderService;
use crate::application::services::mount_registry::MountConfig;
use crate::common::di::AppState as GlobalAppState;
use crate::domain::entities::file::File;
use crate::domain::services::external_mount_id::{
NodeId, encode_child_id, virtual_file_etag, virtual_folder_etag,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
@@ -485,6 +491,28 @@ pub async fn list_folder_resources(
reverse: q.reverse,
};
// External mount branch: a mount-root UUID or an `ext:` id lists live from
// the provider instead of the PostgreSQL UNION. The parent of each entry is
// the requested id itself.
match service.mount_router().classify(&id) {
ResolvedId::MountRoot { cfg } => {
return list_mount_dir_response(
&service,
&cfg,
&NodeId::default(),
&id,
auth_user.id,
opts,
)
.await;
}
ResolvedId::MountChild { cfg, node_id } => {
return list_mount_dir_response(&service, &cfg, &node_id, &id, auth_user.id, opts)
.await;
}
ResolvedId::Regular => {}
}
match service
.list_resources_paged_with_perms(&id, auth_user.id, opts)
.await
@@ -578,3 +606,176 @@ pub async fn list_folder_resources(
Err(e) => AppError::from(e).into_response(),
}
}
/// List one directory inside an external mount and render the standard
/// `/resources` envelope, mapping each live provider entry to a
/// `FolderResourceItemDto` with a synthetic `ext:` id. `parent_id` is the
/// requested id (the directory being listed), which becomes each entry's parent.
async fn list_mount_dir_response(
service: &FolderService,
cfg: &MountConfig,
node_id: &NodeId,
parent_id: &str,
caller_id: uuid::Uuid,
opts: ListResourcesOptions<'_>,
) -> axum::response::Response {
match service
.list_mount_dir_with_perms(cfg, node_id, caller_id, opts)
.await
{
Ok((entries, next_cursor)) => {
let items: Vec<FolderResourceItemDto> = entries
.into_iter()
.map(|entry| mount_entry_to_item(cfg, parent_id, entry))
.collect();
(
StatusCode::OK,
Json(FolderResourcesDto::with_cursor(items, next_cursor)),
)
.into_response()
}
Err(e) => AppError::from(e).into_response(),
}
}
/// Map a live mount entry to a `/resources` item with a synthetic `ext:` id and
/// virtual (size+mtime / mtime) etag. Mount entries have no blob hash.
fn mount_entry_to_item(
cfg: &MountConfig,
parent_id: &str,
entry: MountEntry,
) -> FolderResourceItemDto {
let id = encode_child_id(cfg.mount_id, entry.node_id.clone());
if entry.is_dir {
let dto = FolderDto {
etag: virtual_folder_etag(entry.modified_at),
id,
name: entry.name.clone(),
path: String::new(),
parent_id: Some(parent_id.to_owned()),
owner_id: Some(cfg.owner_id.to_string()),
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: None,
updated_by: None,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
resource: ResourceContentDto::Folder(dto),
}
} else {
let mime = mime_guess::from_path(&entry.name)
.first_or_octet_stream()
.to_string();
let dto = FileDto {
id,
name: entry.name.clone(),
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(&entry.name, &mime)),
icon_special_class: Arc::from(icon_special_class_for(&entry.name, &mime)),
category: Arc::from(category_for(&entry.name, &mime)),
size_formatted: format_file_size(entry.size),
owner_id: Some(cfg.owner_id.to_string()),
sort_date: None,
content_hash: String::new(),
etag: virtual_file_etag(entry.size, entry.modified_at),
created_by: None,
updated_by: None,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
resource: ResourceContentDto::File(dto),
}
}
}
#[cfg(test)]
mod mount_mapping_tests {
use super::*;
use crate::application::services::mount_registry::MountConfig;
use crate::infrastructure::services::local_fs_mount_provider::LocalFsMountProvider;
use uuid::Uuid;
fn config() -> MountConfig {
let dir = tempfile::tempdir().unwrap();
// Leak the tempdir so the path stays valid for the provider's lifetime;
// the provider is never exercised here (mapping is pure metadata).
let path = dir.keep();
MountConfig {
mount_id: Uuid::new_v4(),
kind: "local_fs".to_string(),
name: "Media".to_string(),
owner_id: Uuid::new_v4(),
drive_id: Uuid::new_v4(),
read_only: false,
mount_path: "Personal/Media".to_string(),
provider: Arc::new(LocalFsMountProvider::new(&path, false).unwrap()),
}
}
fn mount_entry(name: &str, node_id: &str, is_dir: bool, size: u64, mtime: u64) -> MountEntry {
MountEntry {
name: name.to_string(),
node_id: NodeId(node_id.to_string()),
is_dir,
size,
modified_at: mtime,
created_at: mtime,
}
}
#[test]
fn maps_folder_entry_to_item() {
let cfg = config();
let parent = cfg.mount_id.to_string();
let item = mount_entry_to_item(&cfg, &parent, mount_entry("docs", "docs", true, 0, 1234));
assert!(matches!(item.resource_type, ResourceTypeDto::Folder));
let ResourceContentDto::Folder(dto) = item.resource else {
panic!("expected folder");
};
assert_eq!(dto.name, "docs");
// id is the synthetic ext: envelope for (mount_id, node_id).
assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs"));
assert_eq!(dto.parent_id.as_deref(), Some(parent.as_str()));
assert_eq!(dto.etag, virtual_folder_etag(1234));
assert_eq!(dto.drive_id, cfg.drive_id);
assert!(!dto.is_root);
// Hierarchy is intentionally cleared on this listing.
assert_eq!(dto.path, "");
}
#[test]
fn maps_file_entry_to_item_with_virtual_etag_and_no_hash() {
let cfg = config();
let parent = encode_child_id(cfg.mount_id, "docs");
let item = mount_entry_to_item(
&cfg,
&parent,
mount_entry("report.json", "docs/report.json", false, 42, 999),
);
assert!(matches!(item.resource_type, ResourceTypeDto::File));
let ResourceContentDto::File(dto) = item.resource else {
panic!("expected file");
};
assert_eq!(dto.id, encode_child_id(cfg.mount_id, "docs/report.json"));
assert_eq!(dto.folder_id.as_deref(), Some(parent.as_str()));
assert_eq!(dto.size, 42);
assert_eq!(dto.etag, virtual_file_etag(42, 999));
// Virtual files have no blob hash.
assert_eq!(dto.content_hash, "");
// Mime is sniffed from the name.
assert_eq!(&*dto.mime_type, "application/json");
}
}
+6
View File
@@ -12,6 +12,12 @@ pub mod interfaces;
#[cfg(integration_tests)]
pub mod integration_test_support;
// Shared testcontainers-backed harness for external-mount integration tests.
// Gated on `test` too because it links the `testcontainers` dev-dependency,
// which is only available to test targets (not the plain lib build).
#[cfg(all(test, integration_tests))]
mod mount_it_support;
// Phase 0 perf-benchmark support: deterministic image corpus generation/loading
// shared by `benches/thumbnails.rs` and `examples/bench_thumbnails_mem.rs`.
// Gated behind the `bench` feature so it adds nothing to normal builds.
+118
View File
@@ -0,0 +1,118 @@
//! 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");
}