diff --git a/Cargo.lock b/Cargo.lock index 08684cc4..96de9a9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 26ad217b..da47dfef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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)'] } diff --git a/migrations/20260805000000_external_mounts.sql b/migrations/20260805000000_external_mounts.sql new file mode 100644 index 00000000..0f3f4088 --- /dev/null +++ b/migrations/20260805000000_external_mounts.sql @@ -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.'; diff --git a/src/application/ports/external_mount_ports.rs b/src/application/ports/external_mount_ports.rs new file mode 100644 index 00000000..b723ab75 --- /dev/null +++ b/src/application/ports/external_mount_ports.rs @@ -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`) 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> + 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, DomainError>; + + /// Stat a single entry. + async fn stat(&self, node_id: &NodeId) -> Result; + + /// 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)>, + ) -> Result; + + /// Create a child directory `name` under `parent`. Returns the new dir's stat. + async fn create_dir(&self, parent: &NodeId, name: &str) -> Result; + + /// 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; + + /// Rename an entry in place (same parent). Returns the renamed entry's stat. + async fn rename(&self, node_id: &NodeId, new_name: &str) -> Result; + + /// 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; +} + +/// 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, 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, DomainError>; +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index ba8fd2b3..55111d00 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -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; diff --git a/src/application/services/batch_operations_test.rs b/src/application/services/batch_operations_test.rs index a01e145c..0148c075 100644 --- a/src/application/services/batch_operations_test.rs +++ b/src/application/services/batch_operations_test.rs @@ -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, diff --git a/src/application/services/external_mount_router.rs b/src/application/services/external_mount_router.rs new file mode 100644 index 00000000..35dbd9d3 --- /dev/null +++ b/src/application/services/external_mount_router.rs @@ -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 }, + /// A synthetic id addressing an entry inside a mount. + MountChild { + cfg: Arc, + node_id: NodeId, + }, +} + +/// Thin, cloneable classifier over the mount registry. +#[derive(Clone)] +pub struct MountRouter { + registry: Arc, +} + +impl MountRouter { + /// Construct from the shared registry. + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + /// Borrow the underlying registry (for path-based resolution / admin reload). + pub fn registry(&self) -> &Arc { + &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); + #[async_trait] + impl ExternalMountRepositoryPort for FakeRepo { + async fn list_all(&self) -> Result, 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 + )); + } +} diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 490a423e..d74e0cbe 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -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, + authz: Arc, + ) -> 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 { + 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)>, + ) -> Result { + 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, diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 9b5cb9fc..a6843ba4 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -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, authz: Arc, + /// External-mount classifier. Lets folder operations branch a mount-root or + /// `ext:` id onto the provider instead of the PostgreSQL repositories. + mount_router: Arc, } impl FolderService { /// Creates a new folder service - pub fn new(folder_storage: Arc, authz: Arc) -> Self { + pub fn new( + folder_storage: Arc, + authz: Arc, + mount_router: Arc, + ) -> 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, Option), 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, + kinds: Option<&[ResourceKind]>, + order_by: &str, + reverse: bool, + limit: usize, + cursor_name: Option<&str>, +) -> (Vec, Option) { + 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 = 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 { + 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) -> Arc { + 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); + } +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 578e3d98..48f56cf6 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -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; diff --git a/src/application/services/mount_registry.rs b/src/application/services/mount_registry.rs new file mode 100644 index 00000000..d81f67aa --- /dev/null +++ b/src/application/services/mount_registry.rs @@ -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, +} + +/// Immutable snapshot swapped atomically on reload. +#[derive(Default)] +struct MountIndex { + /// mount-root folder UUID → config. + by_folder: HashMap>, + /// (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, +} + +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> { + 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, 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, + } + + #[async_trait] + impl ExternalMountRepositoryPort for FakeRepo { + async fn list_all(&self) -> Result, 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"); + } +} diff --git a/src/common/config.rs b/src/common/config.rs index 7549927f..631f9884 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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::()) + && 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() diff --git a/src/common/di.rs b/src/common/di.rs index c37ae31c..dbc0d373 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -518,11 +518,13 @@ impl AppServiceFactory { plugin_dispatch: Option< Arc, >, + mount_router: Arc, ) -> 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>, /// Isolated pool for background / batch operations. pub maintenance_pool: Option>, + /// 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, pub auth_service: Option, pub nextcloud: Option, pub admin_settings_service: Option>, diff --git a/src/domain/services/external_mount_id.rs b/src/domain/services/external_mount_id.rs new file mode 100644 index 00000000..8f868e49 --- /dev/null +++ b/src/domain/services/external_mount_id.rs @@ -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:: +//! ``` +//! +//! * `` 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. +//! * `` 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 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) -> Self { + Self { + mount_id, + node_id: node_id.into(), + } + } +} + +/// Renders as `ext::`. +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::)") + } +} + +impl std::error::Error for ParseMountChildIdError {} + +impl FromStr for MountChildId { + type Err = ParseMountChildIdError; + + fn from_str(s: &str) -> Result { + // ext:: — 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) -> 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 { + 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 `:` 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"); + } +} diff --git a/src/domain/services/mod.rs b/src/domain/services/mod.rs index 42855fe5..dce4a86d 100644 --- a/src/domain/services/mod.rs +++ b/src/domain/services/mod.rs @@ -1,5 +1,6 @@ pub mod authorization; pub mod email_normalize; +pub mod external_mount_id; pub mod i18n_service; pub mod path_service; diff --git a/src/infrastructure/repositories/pg/external_mount_repository.rs b/src/infrastructure/repositories/pg/external_mount_repository.rs new file mode 100644 index 00000000..a18ae48f --- /dev/null +++ b/src/infrastructure/repositories/pg/external_mount_repository.rs @@ -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, +} + +impl ExternalMountPgRepository { + /// Construct over a connection pool. + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ExternalMountRepositoryPort for ExternalMountPgRepository { + async fn list_all(&self) -> Result, 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 = 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()); + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index ecfc3774..979beba5 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -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; diff --git a/src/infrastructure/services/local_fs_mount_provider.rs b/src/infrastructure/services/local_fs_mount_provider.rs new file mode 100644 index 00000000..ca8316bb --- /dev/null +++ b/src/infrastructure/services/local_fs_mount_provider.rs @@ -0,0 +1,1020 @@ +//! `local_fs` external mount provider — serves a raw host filesystem directory. +//! +//! Bound to one canonicalized `host_path` at construction. `node_id` is the POSIX +//! path relative to that root (so `resolve_path` is identity and ids are NOT +//! stable across renames). Every op funnels through [`LocalFsMountProvider::resolve_existing`] +//! / [`LocalFsMountProvider::resolve_parent`], which reject path traversal and +//! symlink escape by canonicalizing and asserting the result stays under the +//! bound root. + +use std::io::SeekFrom; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures::StreamExt; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio_util::io::ReaderStream; + +use crate::application::ports::blob_storage_ports::BlobStream; +use crate::application::ports::external_mount_ports::{ + ExternalMountProvider, MountByteStream, MountCaps, MountEntry, MountStat, +}; +use crate::domain::errors::DomainError; +use crate::domain::services::external_mount_id::NodeId; +use crate::domain::services::path_service::validate_storage_name; + +/// Read buffer size for streamed file reads (mirrors the blob backend). +const STREAM_CHUNK_SIZE: usize = 256 * 1024; + +/// Directories larger than this refuse to list (bounded in-memory sort/cursor). +const MAX_DIR_ENTRIES: usize = 50_000; + +/// A mount provider backed by a local filesystem directory. +pub struct LocalFsMountProvider { + /// Canonical absolute path of the mount root; the containment anchor. + host_path: PathBuf, + /// Whether mutations are refused. + read_only: bool, +} + +impl LocalFsMountProvider { + /// Construct from a host directory path. The path must exist and be a + /// directory; it is canonicalized once so symlink-containment checks are + /// cheap thereafter. + pub fn new(path: impl AsRef, read_only: bool) -> Result { + let host_path = std::fs::canonicalize(path.as_ref()).map_err(|e| { + DomainError::internal_error( + "ExternalMount", + format!( + "mount host path {} is not accessible: {e}", + path.as_ref().display() + ), + ) + })?; + if !host_path.is_dir() { + return Err(DomainError::internal_error( + "ExternalMount", + format!("mount host path {} is not a directory", host_path.display()), + )); + } + Ok(Self { + host_path, + read_only, + }) + } + + /// Lexically join a relative node id onto the host root, rejecting any + /// traversal, absolute, or otherwise unsafe component. Does NOT touch disk. + fn lexical_path(&self, relpath: &str) -> Result { + if relpath.is_empty() { + return Ok(self.host_path.clone()); + } + if relpath.starts_with('/') || relpath.contains('\0') || relpath.contains('\\') { + return Err(DomainError::not_found("ExternalMount", relpath)); + } + let mut out = self.host_path.clone(); + for segment in relpath.split('/') { + // Reject empty / "." / ".." and re-use the storage-name validator + // (no slashes, no null, not "."/".."). + if validate_storage_name(segment).is_err() { + return Err(DomainError::not_found("ExternalMount", relpath)); + } + out.push(segment); + } + Ok(out) + } + + /// Assert `candidate` (once canonicalized) stays under the host root. + /// Returns the canonical path to use for the actual op (so we never follow + /// an escaping symlink). + async fn assert_within(&self, candidate: &Path, relpath: &str) -> Result { + let canonical = tokio::fs::canonicalize(candidate) + .await + .map_err(|e| map_io_err(e, relpath))?; + if !canonical.starts_with(&self.host_path) { + // Symlink escape or traversal — treat as not found (anti-enumeration). + return Err(DomainError::not_found("ExternalMount", relpath)); + } + Ok(canonical) + } + + /// Resolve an existing entry to its canonical on-disk path. + async fn resolve_existing(&self, node: &NodeId) -> Result { + let lexical = self.lexical_path(node.as_str())?; + self.assert_within(&lexical, node.as_str()).await + } + + /// Resolve a parent directory (must exist) to its canonical path, for + /// create/write ops whose final target does not exist yet. + async fn resolve_parent(&self, parent: &NodeId) -> Result { + let lexical = self.lexical_path(parent.as_str())?; + let canonical = self.assert_within(&lexical, parent.as_str()).await?; + if !canonical.is_dir() { + return Err(DomainError::not_found("ExternalMount", parent.as_str())); + } + Ok(canonical) + } + + /// Refuse mutations on read-only mounts. + fn ensure_writable(&self) -> Result<(), DomainError> { + if self.read_only { + return Err(DomainError::operation_not_supported( + "ExternalMount", + "mount is read-only", + )); + } + Ok(()) + } + + /// Refuse operations that target the mount root itself (empty node id). + /// + /// Without this, `delete("")` would `remove_dir_all` the entire mount root + /// and `rename("", …)` would move the root *outside* the containment anchor + /// (its parent directory lives outside the mount). The mount root is managed + /// as a `storage.folders` row, never through the provider. + fn ensure_not_root(node: &NodeId) -> Result<(), DomainError> { + if node.as_str().is_empty() { + return Err(DomainError::operation_not_supported( + "ExternalMount", + "the mount root itself cannot be modified through the provider", + )); + } + Ok(()) + } + + /// Build a child node id given a parent relpath and child name. + fn child_node_id(parent_relpath: &str, name: &str) -> NodeId { + if parent_relpath.is_empty() { + NodeId(name.to_string()) + } else { + NodeId(format!("{parent_relpath}/{name}")) + } + } + + /// Turn a path + its metadata into a [`MountStat`]. + fn stat_from_meta(node_id: NodeId, name: &str, meta: &std::fs::Metadata) -> MountStat { + let is_dir = meta.is_dir(); + MountStat { + node_id, + is_dir, + size: if is_dir { 0 } else { meta.len() }, + modified_at: system_time_secs(meta.modified().ok()), + created_at: system_time_secs(meta.created().ok().or_else(|| meta.modified().ok())), + mime_type: if is_dir { + "directory".to_string() + } else { + mime_guess::from_path(name) + .first_or_octet_stream() + .to_string() + }, + } + } +} + +/// Map a `std::io::Error` to a `DomainError`, preserving anti-enumeration for +/// not-found and a stable shape for the rest. +fn map_io_err(e: std::io::Error, relpath: &str) -> DomainError { + use std::io::ErrorKind as Io; + match e.kind() { + Io::NotFound => DomainError::not_found("ExternalMount", relpath), + Io::PermissionDenied => { + DomainError::access_denied("ExternalMount", format!("permission denied: {relpath}")) + } + Io::AlreadyExists => DomainError::already_exists("ExternalMount", relpath), + _ => DomainError::internal_error("ExternalMount", format!("io error on {relpath}: {e}")), + } +} + +/// Convert an optional `SystemTime` to unix seconds (0 when unavailable). +fn system_time_secs(t: Option) -> u64 { + t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Final path segment of a node id, for display + mime sniffing. +fn node_name(relpath: &str) -> &str { + relpath.rsplit('/').next().unwrap_or(relpath) +} + +#[async_trait] +impl ExternalMountProvider for LocalFsMountProvider { + fn kind(&self) -> &'static str { + "local_fs" + } + + fn capabilities(&self) -> MountCaps { + MountCaps { + supports_range: true, + read_only: self.read_only, + stable_ids: false, + } + } + + // resolve_path uses the default identity impl (node_id == relpath). + + async fn list_dir(&self, node_id: &NodeId) -> Result, DomainError> { + let dir = self.resolve_existing(node_id).await?; + let parent_rel = node_id.as_str(); + + let mut read_dir = tokio::fs::read_dir(&dir) + .await + .map_err(|e| map_io_err(e, parent_rel))?; + + let mut entries = Vec::new(); + while let Some(dirent) = read_dir + .next_entry() + .await + .map_err(|e| map_io_err(e, parent_rel))? + { + // Reject any non-UTF8 / unsafe name (cannot round-trip through ids). + let os_name = dirent.file_name(); + let Some(name) = os_name.to_str() else { + continue; + }; + if validate_storage_name(name).is_err() { + continue; + } + // Skip symlinks that escape the mount root (containment check). + let meta = match dirent.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + if meta.file_type().is_symlink() { + let child_lex = dir.join(name); + if self.assert_within(&child_lex, name).await.is_err() { + continue; + } + } + let node_id = Self::child_node_id(parent_rel, name); + let is_dir = meta.is_dir(); + entries.push(MountEntry { + name: name.to_string(), + node_id, + is_dir, + size: if is_dir { 0 } else { meta.len() }, + modified_at: system_time_secs(meta.modified().ok()), + created_at: system_time_secs(meta.created().ok().or_else(|| meta.modified().ok())), + }); + + if entries.len() > MAX_DIR_ENTRIES { + return Err(DomainError::operation_not_supported( + "ExternalMount", + format!("directory exceeds {MAX_DIR_ENTRIES} entries"), + )); + } + } + Ok(entries) + } + + async fn stat(&self, node_id: &NodeId) -> Result { + let path = self.resolve_existing(node_id).await?; + let meta = tokio::fs::metadata(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + Ok(Self::stat_from_meta( + node_id.clone(), + node_name(node_id.as_str()), + &meta, + )) + } + + async fn open_read_stream( + &self, + node_id: &NodeId, + range: Option<(u64, Option)>, + ) -> Result { + let path = self.resolve_existing(node_id).await?; + let mut file = tokio::fs::File::open(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + + match range { + Some((start, end_inclusive)) => { + file.seek(SeekFrom::Start(start)) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + if let Some(end) = end_inclusive { + // HTTP range end is inclusive. + let limit = end.saturating_sub(start).saturating_add(1); + let limited = file.take(limit); + Ok( + Box::pin(ReaderStream::with_capacity(limited, STREAM_CHUNK_SIZE)) + as BlobStream, + ) + } else { + Ok( + Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE)) + as BlobStream, + ) + } + } + None => { + Ok(Box::pin(ReaderStream::with_capacity(file, STREAM_CHUNK_SIZE)) as BlobStream) + } + } + } + + async fn create_dir(&self, parent: &NodeId, name: &str) -> Result { + self.ensure_writable()?; + validate_name(name)?; + let parent_path = self.resolve_parent(parent).await?; + let target = parent_path.join(name); + tokio::fs::create_dir(&target) + .await + .map_err(|e| map_io_err(e, name))?; + let node_id = Self::child_node_id(parent.as_str(), name); + self.stat(&node_id).await + } + + async fn write_stream( + &self, + parent: &NodeId, + name: &str, + mut body: MountByteStream, + ) -> Result { + self.ensure_writable()?; + validate_name(name)?; + let parent_path = self.resolve_parent(parent).await?; + let target = parent_path.join(name); + + // Stream to disk; on ANY error (create / stream / write / flush) the + // partial file is removed so a retry starts from a clean slate. + let write_result = async { + let mut file = tokio::fs::File::create(&target) + .await + .map_err(|e| map_io_err(e, name))?; + while let Some(chunk) = body.next().await { + let bytes: Bytes = chunk.map_err(|e| { + DomainError::internal_error( + "ExternalMount", + format!("upload stream error: {e}"), + ) + })?; + file.write_all(&bytes) + .await + .map_err(|e| map_io_err(e, name))?; + } + file.flush().await.map_err(|e| map_io_err(e, name))?; + Ok::<(), DomainError>(()) + } + .await; + + if let Err(e) = write_result { + let _ = tokio::fs::remove_file(&target).await; + return Err(e); + } + + let node_id = Self::child_node_id(parent.as_str(), name); + self.stat(&node_id).await + } + + async fn rename(&self, node_id: &NodeId, new_name: &str) -> Result { + self.ensure_writable()?; + Self::ensure_not_root(node_id)?; + validate_name(new_name)?; + let from = self.resolve_existing(node_id).await?; + let parent = from.parent().ok_or_else(|| { + DomainError::operation_not_supported("ExternalMount", "cannot rename mount root") + })?; + let to = parent.join(new_name); + tokio::fs::rename(&from, &to) + .await + .map_err(|e| map_io_err(e, new_name))?; + // Recompute the node id for the new name (same parent). + let parent_rel = parent_relpath(node_id.as_str()); + let new_node = Self::child_node_id(parent_rel, new_name); + self.stat(&new_node).await + } + + async fn delete(&self, node_id: &NodeId) -> Result<(), DomainError> { + self.ensure_writable()?; + Self::ensure_not_root(node_id)?; + let path = self.resolve_existing(node_id).await?; + let meta = tokio::fs::metadata(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + if meta.is_dir() { + tokio::fs::remove_dir_all(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str())) + } else { + tokio::fs::remove_file(&path) + .await + .map_err(|e| map_io_err(e, node_id.as_str())) + } + } + + async fn move_within( + &self, + node_id: &NodeId, + dest_parent: &NodeId, + ) -> Result { + self.ensure_writable()?; + Self::ensure_not_root(node_id)?; + let from = self.resolve_existing(node_id).await?; + let name = node_name(node_id.as_str()).to_string(); + let dest_dir = self.resolve_parent(dest_parent).await?; + let to = dest_dir.join(&name); + tokio::fs::rename(&from, &to) + .await + .map_err(|e| map_io_err(e, node_id.as_str()))?; + let new_node = Self::child_node_id(dest_parent.as_str(), &name); + self.stat(&new_node).await + } +} + +/// Validate a single new name component (mkdir / upload / rename target). +fn validate_name(name: &str) -> Result<(), DomainError> { + validate_storage_name(name) + .map_err(|reason| DomainError::validation_error(format!("invalid name '{name}': {reason}"))) +} + +/// Parent relpath of a node id (`""` when the node is a direct child of root). +fn parent_relpath(relpath: &str) -> &str { + match relpath.rsplit_once('/') { + Some((parent, _)) => parent, + None => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn provider(dir: &Path) -> LocalFsMountProvider { + LocalFsMountProvider::new(dir, false).expect("provider") + } + + #[tokio::test] + async fn lists_and_stats_real_files() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("sub")).unwrap(); + std::fs::write(dir.path().join("a.txt"), b"hello").unwrap(); + let p = provider(dir.path()); + + let mut entries = p.list_dir(&NodeId("".into())).await.unwrap(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "a.txt"); + assert!(!entries[0].is_dir); + assert_eq!(entries[0].size, 5); + assert_eq!(entries[1].name, "sub"); + assert!(entries[1].is_dir); + + let stat = p.stat(&NodeId("a.txt".into())).await.unwrap(); + assert_eq!(stat.size, 5); + assert!(!stat.is_dir); + } + + #[tokio::test] + async fn rejects_traversal_and_absolute() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + assert!(p.stat(&NodeId("../escape".into())).await.is_err()); + assert!(p.stat(&NodeId("/etc/passwd".into())).await.is_err()); + assert!(p.stat(&NodeId("a/../../b".into())).await.is_err()); + } + + #[cfg(unix)] + #[tokio::test] + async fn rejects_symlink_escape() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"top secret").unwrap(); + std::os::unix::fs::symlink(outside.path().join("secret.txt"), dir.path().join("link")) + .unwrap(); + let p = provider(dir.path()); + // Stat through the escaping symlink must be rejected... + assert!(p.stat(&NodeId("link".into())).await.is_err()); + // ...and it must not appear in listings. + let entries = p.list_dir(&NodeId("".into())).await.unwrap(); + assert!(entries.iter().all(|e| e.name != "link")); + } + + #[tokio::test] + async fn read_stream_round_trips_and_ranges() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f.bin"), b"0123456789").unwrap(); + let p = provider(dir.path()); + + let full: Vec = p + .open_read_stream(&NodeId("f.bin".into()), None) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(full, b"0123456789"); + + // bytes 2..=5 inclusive => "2345" + let part: Vec = p + .open_read_stream(&NodeId("f.bin".into()), Some((2, Some(5)))) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(part, b"2345"); + } + + #[tokio::test] + async fn write_mkdir_rename_delete_round_trip() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + + // mkdir + let d = p.create_dir(&NodeId("".into()), "folder").await.unwrap(); + assert!(d.is_dir); + + // write into it + let body: MountByteStream = + Box::pin(stream::once(async { Ok(Bytes::from_static(b"data")) })); + let f = p + .write_stream(&NodeId("folder".into()), "x.txt", body) + .await + .unwrap(); + assert_eq!(f.size, 4); + assert_eq!(f.node_id.as_str(), "folder/x.txt"); + + // rename + let r = p + .rename(&NodeId("folder/x.txt".into()), "y.txt") + .await + .unwrap(); + assert_eq!(r.node_id.as_str(), "folder/y.txt"); + + // delete + p.delete(&NodeId("folder/y.txt".into())).await.unwrap(); + assert!(p.stat(&NodeId("folder/y.txt".into())).await.is_err()); + } + + #[tokio::test] + async fn read_only_refuses_mutations() { + let dir = tempdir().unwrap(); + let p = LocalFsMountProvider::new(dir.path(), true).unwrap(); + assert!(p.create_dir(&NodeId("".into()), "nope").await.is_err()); + assert!(p.capabilities().read_only); + // Read paths still work on a read-only mount. + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + assert!(p.stat(&NodeId("a.txt".into())).await.is_ok()); + } + + // ── construction ──────────────────────────────────────────────────────── + + #[tokio::test] + async fn new_rejects_missing_path() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("does-not-exist"); + assert!(LocalFsMountProvider::new(&missing, false).is_err()); + } + + #[tokio::test] + async fn new_rejects_file_as_root() { + let dir = tempdir().unwrap(); + let file = dir.path().join("f"); + std::fs::write(&file, b"x").unwrap(); + assert!(LocalFsMountProvider::new(&file, false).is_err()); + } + + #[test] + fn kind_and_capabilities() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + assert_eq!(p.kind(), "local_fs"); + let caps = p.capabilities(); + assert!(caps.supports_range); + assert!(!caps.read_only); + assert!(!caps.stable_ids); + } + + // ── nested listing + node ids ─────────────────────────────────────────── + + #[tokio::test] + async fn lists_nested_directory_with_relative_node_ids() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("a/b")).unwrap(); + std::fs::write(dir.path().join("a/b/c.txt"), b"hi").unwrap(); + let p = provider(dir.path()); + + let entries = p.list_dir(&NodeId("a/b".into())).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "c.txt"); + // node id is the FULL relative path, not just the name. + assert_eq!(entries[0].node_id.as_str(), "a/b/c.txt"); + + // resolve_path is identity for local_fs. + assert_eq!(p.resolve_path("a/b/c.txt").as_str(), "a/b/c.txt"); + } + + #[tokio::test] + async fn list_dir_skips_unsafe_entry_names() { + // A file literally named ".." can't exist, but a name with a leading + // dot is fine; verify dotfiles ARE listed (only traversal tokens are + // rejected, and the fs never yields "."/".."). + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join(".hidden"), b"x").unwrap(); + let p = provider(dir.path()); + let entries = p.list_dir(&NodeId("".into())).await.unwrap(); + assert!(entries.iter().any(|e| e.name == ".hidden")); + } + + // ── stat error paths ──────────────────────────────────────────────────── + + #[tokio::test] + async fn stat_directory_reports_dir_and_zero_size() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("d")).unwrap(); + let p = provider(dir.path()); + let s = p.stat(&NodeId("d".into())).await.unwrap(); + assert!(s.is_dir); + assert_eq!(s.size, 0); + assert_eq!(s.mime_type, "directory"); + assert_eq!(s.node_id.as_str(), "d"); + } + + #[tokio::test] + async fn stat_missing_is_not_found() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let err = p.stat(&NodeId("nope.txt".into())).await.unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + #[tokio::test] + async fn stat_sniffs_mime_from_extension() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("p.json"), b"{}").unwrap(); + let p = provider(dir.path()); + let s = p.stat(&NodeId("p.json".into())).await.unwrap(); + assert_eq!(s.mime_type, "application/json"); + } + + // ── range edge cases ──────────────────────────────────────────────────── + + #[tokio::test] + async fn range_to_end_when_end_is_none() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f"), b"0123456789").unwrap(); + let p = provider(dir.path()); + let out: Vec = p + .open_read_stream(&NodeId("f".into()), Some((7, None))) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(out, b"789"); + } + + #[tokio::test] + async fn range_end_past_eof_is_clamped_by_fs() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f"), b"abc").unwrap(); + let p = provider(dir.path()); + // end well past EOF — take() simply yields what exists. + let out: Vec = p + .open_read_stream(&NodeId("f".into()), Some((1, Some(99)))) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(out, b"bc"); + } + + #[tokio::test] + async fn open_missing_file_is_not_found() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + // BlobStream is not Debug, so match rather than unwrap_err. + let err = match p.open_read_stream(&NodeId("ghost".into()), None).await { + Ok(_) => panic!("expected an error opening a missing file"), + Err(e) => e, + }; + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + // ── create / write error paths ────────────────────────────────────────── + + #[tokio::test] + async fn create_dir_existing_is_already_exists() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("d")).unwrap(); + let p = provider(dir.path()); + let err = p.create_dir(&NodeId("".into()), "d").await.unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::AlreadyExists); + } + + #[tokio::test] + async fn create_dir_rejects_invalid_name() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + // names with separators / traversal are validation errors. + assert!(p.create_dir(&NodeId("".into()), "a/b").await.is_err()); + assert!(p.create_dir(&NodeId("".into()), "..").await.is_err()); + assert!(p.create_dir(&NodeId("".into()), "").await.is_err()); + } + + #[tokio::test] + async fn create_under_missing_parent_is_not_found() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let err = p + .create_dir(&NodeId("ghost".into()), "child") + .await + .unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + #[tokio::test] + async fn write_overwrites_existing_file() { + use futures::stream; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f.txt"), b"old-and-longer").unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = + Box::pin(stream::once(async { Ok(Bytes::from_static(b"new")) })); + let s = p + .write_stream(&NodeId("".into()), "f.txt", body) + .await + .unwrap(); + assert_eq!(s.size, 3); + assert_eq!( + std::fs::read(dir.path().join("f.txt")).unwrap(), + b"new".to_vec() + ); + } + + #[tokio::test] + async fn write_multi_chunk_concatenates() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = Box::pin(stream::iter(vec![ + Ok(Bytes::from_static(b"foo")), + Ok(Bytes::from_static(b"bar")), + Ok(Bytes::from_static(b"baz")), + ])); + let s = p + .write_stream(&NodeId("".into()), "m.txt", body) + .await + .unwrap(); + assert_eq!(s.size, 9); + assert_eq!( + std::fs::read(dir.path().join("m.txt")).unwrap(), + b"foobarbaz" + ); + } + + #[tokio::test] + async fn write_mid_stream_error_removes_partial_file() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = Box::pin(stream::iter(vec![ + Ok(Bytes::from_static(b"partial")), + Err(std::io::Error::other("boom")), + ])); + let err = p + .write_stream(&NodeId("".into()), "broken.txt", body) + .await + .unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::InternalError); + // The partial file must not be left behind. + assert!(!dir.path().join("broken.txt").exists()); + } + + // ── root-mutation guard (security) ────────────────────────────────────── + + #[tokio::test] + async fn root_cannot_be_deleted() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("keep.txt"), b"x").unwrap(); + let p = provider(dir.path()); + let err = p.delete(&NodeId("".into())).await.unwrap_err(); + assert_eq!( + err.kind, + crate::domain::errors::ErrorKind::UnsupportedOperation + ); + // The mount root and its contents survive. + assert!(dir.path().exists()); + assert!(dir.path().join("keep.txt").exists()); + } + + #[tokio::test] + async fn root_cannot_be_renamed_or_moved() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("d")).unwrap(); + let p = provider(dir.path()); + assert!(p.rename(&NodeId("".into()), "escaped").await.is_err()); + assert!( + p.move_within(&NodeId("".into()), &NodeId("d".into())) + .await + .is_err() + ); + assert!(dir.path().exists()); + } + + // ── rename / move semantics ───────────────────────────────────────────── + + #[tokio::test] + async fn rename_rejects_invalid_new_name() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let p = provider(dir.path()); + assert!(p.rename(&NodeId("a.txt".into()), "b/c").await.is_err()); + assert!(p.rename(&NodeId("a.txt".into()), "..").await.is_err()); + } + + #[tokio::test] + async fn move_within_relocates_into_subdir() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + std::fs::create_dir(dir.path().join("dest")).unwrap(); + let p = provider(dir.path()); + let s = p + .move_within(&NodeId("a.txt".into()), &NodeId("dest".into())) + .await + .unwrap(); + assert_eq!(s.node_id.as_str(), "dest/a.txt"); + assert!(!dir.path().join("a.txt").exists()); + assert!(dir.path().join("dest/a.txt").exists()); + } + + #[tokio::test] + async fn move_into_missing_dest_is_not_found() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let p = provider(dir.path()); + let err = p + .move_within(&NodeId("a.txt".into()), &NodeId("ghost".into())) + .await + .unwrap_err(); + assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound); + } + + #[tokio::test] + async fn delete_directory_recursively() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("d/e")).unwrap(); + std::fs::write(dir.path().join("d/e/f.txt"), b"x").unwrap(); + let p = provider(dir.path()); + p.delete(&NodeId("d".into())).await.unwrap(); + assert!(!dir.path().join("d").exists()); + } + + // ── map_io_err mapping table ──────────────────────────────────────────── + + #[test] + fn io_error_mapping() { + use crate::domain::errors::ErrorKind; + use std::io::{Error, ErrorKind as Io}; + assert_eq!( + map_io_err(Error::from(Io::NotFound), "x").kind, + ErrorKind::NotFound + ); + assert_eq!( + map_io_err(Error::from(Io::PermissionDenied), "x").kind, + ErrorKind::AccessDenied + ); + assert_eq!( + map_io_err(Error::from(Io::AlreadyExists), "x").kind, + ErrorKind::AlreadyExists + ); + assert_eq!( + map_io_err(Error::other("weird"), "x").kind, + ErrorKind::InternalError + ); + } + + #[test] + fn parent_relpath_and_node_name_helpers() { + assert_eq!(parent_relpath("a/b/c"), "a/b"); + assert_eq!(parent_relpath("top"), ""); + assert_eq!(node_name("a/b/c.txt"), "c.txt"); + assert_eq!(node_name("solo"), "solo"); + } + #[tokio::test] + async fn lists_empty_directory() { + let dir = tempdir().unwrap(); + std::fs::create_dir(dir.path().join("empty")).unwrap(); + let p = provider(dir.path()); + let entries = p.list_dir(&NodeId("empty".into())).await.unwrap(); + assert!(entries.is_empty()); + } + + #[tokio::test] + async fn list_dir_on_a_file_errors() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("f.txt"), b"x").unwrap(); + let p = provider(dir.path()); + assert!(p.list_dir(&NodeId("f.txt".into())).await.is_err()); + } + + #[tokio::test] + async fn stat_root_reports_directory() { + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let s = p.stat(&NodeId("".into())).await.unwrap(); + assert!(s.is_dir); + assert_eq!(s.node_id.as_str(), ""); + } + + #[tokio::test] + async fn write_empty_file_zero_chunks() { + use futures::stream; + let dir = tempdir().unwrap(); + let p = provider(dir.path()); + let body: MountByteStream = Box::pin(stream::empty()); + let s = p + .write_stream(&NodeId("".into()), "empty.txt", body) + .await + .unwrap(); + assert_eq!(s.size, 0); + assert!(dir.path().join("empty.txt").exists()); + } + + #[tokio::test] + async fn rename_onto_existing_sibling() { + // POSIX rename replaces an existing file at the destination. + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"aaa").unwrap(); + std::fs::write(dir.path().join("b.txt"), b"b").unwrap(); + let p = provider(dir.path()); + let r = p.rename(&NodeId("a.txt".into()), "b.txt").await.unwrap(); + assert_eq!(r.node_id.as_str(), "b.txt"); + assert_eq!(r.size, 3); + assert!(!dir.path().join("a.txt").exists()); + } + + #[tokio::test] + async fn move_dir_into_own_subtree_is_rejected_by_os() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("d/inner")).unwrap(); + let p = provider(dir.path()); + // Moving "d" into "d/inner" is a cycle; the OS refuses it. + assert!( + p.move_within(&NodeId("d".into()), &NodeId("d/inner".into())) + .await + .is_err() + ); + assert!(dir.path().join("d/inner").exists()); + } + + #[tokio::test] + async fn unicode_filenames_round_trip() { + use futures::TryStreamExt; + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("café.txt"), b"\xc3\xa9").unwrap(); + let p = provider(dir.path()); + let entries = p.list_dir(&NodeId("".into())).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "café.txt"); + assert_eq!(entries[0].node_id.as_str(), "café.txt"); + let bytes: Vec = p + .open_read_stream(&entries[0].node_id, None) + .await + .unwrap() + .map_ok(|b| b.to_vec()) + .try_concat() + .await + .unwrap(); + assert_eq!(bytes, b"\xc3\xa9"); + } + + #[tokio::test] + async fn read_only_refuses_all_mutators() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + std::fs::create_dir(dir.path().join("dest")).unwrap(); + let p = LocalFsMountProvider::new(dir.path(), true).unwrap(); + use futures::stream; + let body: MountByteStream = Box::pin(stream::once(async { Ok(Bytes::from_static(b"x")) })); + assert!( + p.write_stream(&NodeId("".into()), "n.txt", body) + .await + .is_err() + ); + assert!(p.rename(&NodeId("a.txt".into()), "b.txt").await.is_err()); + assert!(p.delete(&NodeId("a.txt".into())).await.is_err()); + assert!( + p.move_within(&NodeId("a.txt".into()), &NodeId("dest".into())) + .await + .is_err() + ); + // Read paths still work. + assert!(p.stat(&NodeId("a.txt".into())).await.is_ok()); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index cbdf0a9c..4fa4cb60 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/mount_provider_factory.rs b/src/infrastructure/services/mount_provider_factory.rs new file mode 100644 index 00000000..bf2c80f9 --- /dev/null +++ b/src/infrastructure/services/mount_provider_factory.rs @@ -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, 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` isn't `Debug`, so `unwrap_err` won't + /// compile — extract the error by matching instead. + fn expect_err(r: Result, 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); + } +} diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 00e6312d..961473e9 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -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>, 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, + ¶ms, + &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, + 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 // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 7dc304df..e67a8ab9 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -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 = 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"); + } +} diff --git a/src/lib.rs b/src/lib.rs index ef629325..7e5caccb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. diff --git a/src/mount_it_support.rs b/src/mount_it_support.rs new file mode 100644 index 00000000..85b9dd37 --- /dev/null +++ b/src/mount_it_support.rs @@ -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, Arc) { + 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, + 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"); +}