From 4227ce543c642cf3be89107547e0ce72d4f8fdbc Mon Sep 17 00:00:00 2001
From: AptS-2077
Date: Mon, 6 Jul 2026 16:15:58 +0800
Subject: [PATCH 1/5] feat: add SFTP storage backend
---
Cargo.lock | 1156 +++++++++++++++--
Cargo.toml | 2 +
.../components/admin/StoragePolicyDialog.tsx | 12 +-
.../policyPresentation.test.ts | 1 +
.../admin-policies-page/policyPresentation.ts | 1 +
.../StoragePolicyCreateWizard.tsx | 5 +-
.../StoragePolicyEditForm.tsx | 5 +-
.../StoragePolicyObjectStorageFields.tsx | 37 +-
.../connectionNormalization.ts | 88 +-
.../descriptorPredicates.ts | 11 +
.../src/i18n/locales/en/admin/policies.json | 8 +
.../src/i18n/locales/zh/admin/policies.json | 8 +
.../pages/admin/AdminPoliciesPage.test.tsx | 42 +
.../src/pages/admin/AdminPoliciesPage.tsx | 10 +-
frontend-panel/src/services/api.generated.ts | 2 +-
src/storage/connectors/mod.rs | 42 +-
src/storage/connectors/sftp.rs | 159 +++
src/storage/connectors/tests.rs | 121 +-
src/storage/connectors/upload.rs | 9 +-
src/storage/drivers/mod.rs | 1 +
src/storage/drivers/sftp.rs | 839 ++++++++++++
src/storage/registry.rs | 5 +
src/types/storage_policy.rs | 4 +
23 files changed, 2410 insertions(+), 158 deletions(-)
create mode 100644 src/storage/connectors/sftp.rs
create mode 100644 src/storage/drivers/sftp.rs
diff --git a/Cargo.lock b/Cargo.lock
index 34400a227..749a35c9c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -264,7 +264,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
- "generic-array",
+ "generic-array 0.14.7",
+]
+
+[[package]]
+name = "aead"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
+dependencies = [
+ "crypto-common 0.2.2",
+ "inout 0.2.2",
]
[[package]]
@@ -274,31 +284,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
- "cipher",
+ "cipher 0.4.4",
"cpufeatures 0.2.17",
]
+[[package]]
+name = "aes"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
+dependencies = [
+ "cipher 0.5.2",
+ "cpubits",
+ "cpufeatures 0.3.0",
+ "zeroize",
+]
+
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
- "aead",
- "aes",
- "cipher",
- "ctr",
- "ghash",
+ "aead 0.5.2",
+ "aes 0.8.4",
+ "cipher 0.4.4",
+ "ctr 0.9.2",
+ "ghash 0.5.1",
"subtle",
]
+[[package]]
+name = "aes-gcm"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
+dependencies = [
+ "aead 0.6.1",
+ "aes 0.9.1",
+ "cipher 0.5.2",
+ "ctr 0.10.1",
+ "ghash 0.6.0",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "aes-kw"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
dependencies = [
- "aes",
+ "aes 0.8.4",
]
[[package]]
@@ -482,9 +519,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
- "blake2",
+ "blake2 0.10.6",
"cpufeatures 0.2.17",
- "password-hash",
+ "password-hash 0.5.0",
+]
+
+[[package]]
+name = "argon2"
+version = "0.6.0-rc.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5"
+dependencies = [
+ "base64ct",
+ "blake2 0.11.0-rc.6",
+ "cpufeatures 0.3.0",
+ "password-hash 0.6.1",
]
[[package]]
@@ -710,9 +759,9 @@ dependencies = [
"actix-multipart",
"actix-web",
"actix-ws",
- "aes-gcm",
+ "aes-gcm 0.10.3",
"api-docs-macros",
- "argon2",
+ "argon2 0.5.3",
"async-stream",
"async-trait",
"aws-credential-types",
@@ -763,7 +812,9 @@ dependencies = [
"redis",
"reqwest 0.13.4",
"ring",
- "rsa",
+ "rsa 0.9.10",
+ "russh",
+ "russh-sftp",
"rust-embed",
"sea-orm",
"secrecy",
@@ -991,13 +1042,13 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
- "crypto-bigint",
+ "crypto-bigint 0.5.5",
"form_urlencoded",
"hex",
"hmac 0.13.0",
"http 0.2.12",
"http 1.4.2",
- "p256",
+ "p256 0.13.2",
"percent-encoding",
"sha2 0.11.0",
"subtle",
@@ -1368,6 +1419,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+[[package]]
+name = "base16ct"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -1396,6 +1453,17 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bcrypt-pbkdf"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
+dependencies = [
+ "blowfish",
+ "pbkdf2 0.13.0",
+ "sha2 0.11.0",
+]
+
[[package]]
name = "bigdecimal"
version = "0.4.10"
@@ -1452,13 +1520,22 @@ dependencies = [
"digest 0.10.7",
]
+[[package]]
+name = "blake2"
+version = "0.11.0-rc.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690"
+dependencies = [
+ "digest 0.11.3",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -1468,6 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
+ "zeroize",
]
[[package]]
@@ -1476,7 +1554,26 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
+]
+
+[[package]]
+name = "block-padding"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "blowfish"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
+dependencies = [
+ "byteorder",
+ "cipher 0.5.2",
]
[[package]]
@@ -1696,7 +1793,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
- "cipher",
+ "cipher 0.4.4",
+]
+
+[[package]]
+name = "cbc"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
+dependencies = [
+ "cipher 0.5.2",
]
[[package]]
@@ -1749,8 +1855,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
+ "cipher 0.5.2",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
+ "zeroize",
]
[[package]]
@@ -1811,7 +1919,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
- "inout",
+ "inout 0.1.4",
+]
+
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "inout 0.2.2",
+ "zeroize",
]
[[package]]
@@ -1994,6 +2114,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -2134,19 +2260,36 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
+[[package]]
+name = "crypto-bigint"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
+dependencies = [
+ "cpubits",
+ "ctutils",
+ "getrandom 0.4.3",
+ "hybrid-array",
+ "num-traits",
+ "rand_core 0.10.1",
+ "serdect",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
"rand_core 0.6.4",
"typenum",
]
@@ -2157,6 +2300,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
+ "getrandom 0.4.3",
"hybrid-array",
"rand_core 0.10.1",
]
@@ -2167,40 +2311,40 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "842583c7b5969afdd00c31fd4ffe23f084d62ea3bc07a2132a2a3c4340ab1d09"
dependencies = [
- "aes",
- "aes-gcm",
+ "aes 0.8.4",
+ "aes-gcm 0.10.3",
"aes-kw",
- "argon2",
+ "argon2 0.5.3",
"base64 0.22.1",
- "cbc",
- "cipher",
+ "cbc 0.1.2",
+ "cipher 0.4.4",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"crypto-common 0.2.2",
- "der",
+ "der 0.7.10",
"digest 0.11.3",
- "ecdsa",
- "elliptic-curve",
- "generic-array",
+ "ecdsa 0.16.9",
+ "elliptic-curve 0.13.8",
+ "generic-array 0.14.7",
"hex",
"hkdf 0.12.4",
"hmac 0.12.1",
"hmac 0.13.0",
"hybrid-array",
"kbkdf",
- "p256",
- "p384",
- "p521",
- "pbkdf2",
- "pkcs8",
+ "p256 0.13.2",
+ "p384 0.13.1",
+ "p521 0.13.3",
+ "pbkdf2 0.12.2",
+ "pkcs8 0.10.2",
"rand 0.8.6",
- "rsa",
- "sec1",
+ "rsa 0.9.10",
+ "sec1 0.7.3",
"serde",
"sha1 0.10.6",
"sha2 0.10.9",
"sha2 0.11.0",
- "spki",
+ "spki 0.7.3",
"subtle",
"tracing",
"uuid",
@@ -2208,13 +2352,32 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "crypto-primes"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
+dependencies = [
+ "crypto-bigint 0.7.5",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
- "cipher",
+ "cipher 0.4.4",
+]
+
+[[package]]
+name = "ctr"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
+dependencies = [
+ "cipher 0.5.2",
]
[[package]]
@@ -2224,6 +2387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
+ "subtle",
]
[[package]]
@@ -2236,7 +2400,23 @@ dependencies = [
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
- "fiat-crypto",
+ "fiat-crypto 0.2.9",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "5.0.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "curve25519-dalek-derive",
+ "digest 0.11.3",
+ "fiat-crypto 0.3.0",
"rustc_version",
"subtle",
"zeroize",
@@ -2342,6 +2522,17 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+[[package]]
+name = "delegate"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "der"
version = "0.7.10"
@@ -2351,7 +2542,18 @@ dependencies = [
"const-oid 0.9.6",
"der_derive",
"flagset",
- "pem-rfc7468",
+ "pem-rfc7468 0.7.0",
+ "zeroize",
+]
+
+[[package]]
+name = "der"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
+dependencies = [
+ "const-oid 0.10.2",
+ "pem-rfc7468 1.0.0",
"zeroize",
]
@@ -2423,6 +2625,15 @@ dependencies = [
"unicode-xid",
]
+[[package]]
+name = "des"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
+dependencies = [
+ "cipher 0.5.2",
+]
+
[[package]]
name = "diff"
version = "0.1.13"
@@ -2499,12 +2710,27 @@ version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
- "der",
+ "der 0.7.10",
"digest 0.10.7",
- "elliptic-curve",
- "rfc6979",
- "signature",
- "spki",
+ "elliptic-curve 0.13.8",
+ "rfc6979 0.4.0",
+ "signature 2.2.0",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "ecdsa"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
+dependencies = [
+ "der 0.8.0",
+ "digest 0.11.3",
+ "elliptic-curve 0.14.1",
+ "rfc6979 0.6.0",
+ "signature 3.0.0",
+ "spki 0.8.0",
+ "zeroize",
]
[[package]]
@@ -2513,8 +2739,18 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
- "pkcs8",
- "signature",
+ "pkcs8 0.10.2",
+ "signature 2.2.0",
+]
+
+[[package]]
+name = "ed25519"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
+dependencies = [
+ "pkcs8 0.11.0",
+ "signature 3.0.0",
]
[[package]]
@@ -2523,14 +2759,30 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
- "curve25519-dalek",
- "ed25519",
+ "curve25519-dalek 4.1.3",
+ "ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
+[[package]]
+name = "ed25519-dalek"
+version = "3.0.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849"
+dependencies = [
+ "curve25519-dalek 5.0.0-rc.1",
+ "ed25519 3.0.0",
+ "rand_core 0.10.1",
+ "serde",
+ "sha2 0.11.0",
+ "signature 3.0.0",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "either"
version = "1.16.0"
@@ -2546,17 +2798,39 @@ version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
- "base16ct",
- "crypto-bigint",
+ "base16ct 0.2.0",
+ "crypto-bigint 0.5.5",
"digest 0.10.7",
- "ff",
- "generic-array",
- "group",
+ "ff 0.13.1",
+ "generic-array 0.14.7",
+ "group 0.13.0",
"hkdf 0.12.4",
- "pem-rfc7468",
- "pkcs8",
+ "pem-rfc7468 0.7.0",
+ "pkcs8 0.10.2",
"rand_core 0.6.4",
- "sec1",
+ "sec1 0.7.3",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
+dependencies = [
+ "base16ct 1.0.0",
+ "crypto-bigint 0.7.5",
+ "crypto-common 0.2.2",
+ "digest 0.11.3",
+ "ff 0.14.0",
+ "group 0.14.0",
+ "hkdf 0.13.0",
+ "hybrid-array",
+ "pem-rfc7468 1.0.0",
+ "pkcs8 0.11.0",
+ "rand_core 0.10.1",
+ "sec1 0.8.1",
"subtle",
"zeroize",
]
@@ -2586,6 +2860,18 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "enum_dispatch"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
+dependencies = [
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -2689,12 +2975,28 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "ff"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
+dependencies = [
+ "rand_core 0.10.1",
+ "subtle",
+]
+
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+[[package]]
+name = "fiat-crypto"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
+
[[package]]
name = "filetime"
version = "0.2.29"
@@ -2899,6 +3201,17 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "generic-array"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2e55f16dcf0e9c00efbe2e655ffe45fc98e7066b52bc92f8a79e64060a79351"
+dependencies = [
+ "generic-array 0.14.7",
+ "rustversion",
+ "typenum",
+]
+
[[package]]
name = "geo-types"
version = "0.7.19"
@@ -2944,9 +3257,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -2956,7 +3271,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
- "polyval",
+ "polyval 0.6.2",
+]
+
+[[package]]
+name = "ghash"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
+dependencies = [
+ "polyval 0.7.1",
]
[[package]]
@@ -2975,6 +3299,18 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "gloo-timers"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "governor"
version = "0.10.4"
@@ -3004,11 +3340,22 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
- "ff",
+ "ff 0.13.1",
"rand_core 0.6.4",
"subtle",
]
+[[package]]
+name = "group"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
+dependencies = [
+ "ff 0.14.0",
+ "rand_core 0.10.1",
+ "subtle",
+]
+
[[package]]
name = "h2"
version = "0.3.27"
@@ -3124,6 +3471,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hex-literal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -3238,11 +3591,13 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
-version = "0.4.12"
+version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
+checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
+ "ctutils",
"serde",
+ "subtle",
"typenum",
"zeroize",
]
@@ -3562,8 +3917,30 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
- "block-padding",
- "generic-array",
+ "block-padding 0.3.3",
+ "generic-array 0.14.7",
+]
+
+[[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "block-padding 0.4.2",
+ "hybrid-array",
+]
+
+[[package]]
+name = "internal-russh-num-bigint"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8"
+dependencies = [
+ "num-integer",
+ "num-traits",
+ "rand 0.10.1",
+ "rand_core 0.10.1",
]
[[package]]
@@ -3704,7 +4081,7 @@ dependencies = [
"pem",
"serde",
"serde_json",
- "signature",
+ "signature 2.2.0",
"simple_asn1",
"zeroize",
]
@@ -3718,6 +4095,26 @@ dependencies = [
"digest 0.11.3",
]
+[[package]]
+name = "keccak"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
+name = "kem"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
+dependencies = [
+ "crypto-common 0.2.2",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "language-tags"
version = "0.3.2"
@@ -3937,7 +4334,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
- "nix",
+ "nix 0.29.0",
"serde",
"winapi",
]
@@ -3967,6 +4364,12 @@ dependencies = [
"digest 0.11.3",
]
+[[package]]
+name = "md5"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
+
[[package]]
name = "memchr"
version = "2.8.2"
@@ -4036,6 +4439,31 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "ml-kem"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66"
+dependencies = [
+ "hybrid-array",
+ "kem",
+ "module-lattice",
+ "pkcs8 0.11.0",
+ "rand_core 0.10.1",
+ "sha3",
+]
+
+[[package]]
+name = "module-lattice"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe"
+dependencies = [
+ "ctutils",
+ "hybrid-array",
+ "num-traits",
+]
+
[[package]]
name = "moka"
version = "0.12.15"
@@ -4079,6 +4507,18 @@ dependencies = [
"memoffset",
]
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -4353,16 +4793,16 @@ dependencies = [
"base64 0.21.7",
"chrono",
"dyn-clone",
- "ed25519-dalek",
+ "ed25519-dalek 2.2.0",
"hmac 0.12.1",
"http 1.4.2",
"itertools 0.10.5",
"log",
"oauth2",
- "p256",
- "p384",
+ "p256 0.13.2",
+ "p384 0.13.1",
"rand 0.8.6",
- "rsa",
+ "rsa 0.9.10",
"serde",
"serde-value",
"serde_json",
@@ -4435,38 +4875,99 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
- "ecdsa",
- "elliptic-curve",
- "primeorder",
+ "ecdsa 0.16.9",
+ "elliptic-curve 0.13.8",
+ "primeorder 0.13.6",
"sha2 0.10.9",
]
+[[package]]
+name = "p256"
+version = "0.14.0-rc.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bb40a5099e2c38a09dd29321a7a7f045f165a54317679c7cdfb0cbaf8f6b1e"
+dependencies = [
+ "ecdsa 0.17.0",
+ "elliptic-curve 0.14.1",
+ "primefield",
+ "primeorder 0.14.0",
+ "sha2 0.11.0",
+]
+
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
- "ecdsa",
- "elliptic-curve",
- "primeorder",
+ "ecdsa 0.16.9",
+ "elliptic-curve 0.13.8",
+ "primeorder 0.13.6",
"sha2 0.10.9",
]
+[[package]]
+name = "p384"
+version = "0.14.0-rc.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "492f329d7eb11d22dadc988626b9ea1f503b4ab043a8b1e4e2cc4ae45dabdd70"
+dependencies = [
+ "ecdsa 0.17.0",
+ "elliptic-curve 0.14.1",
+ "fiat-crypto 0.3.0",
+ "primefield",
+ "primeorder 0.14.0",
+ "sha2 0.11.0",
+]
+
[[package]]
name = "p521"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
dependencies = [
- "base16ct",
- "ecdsa",
- "elliptic-curve",
- "primeorder",
+ "base16ct 0.2.0",
+ "ecdsa 0.16.9",
+ "elliptic-curve 0.13.8",
+ "primeorder 0.13.6",
"rand_core 0.6.4",
"sha2 0.10.9",
]
+[[package]]
+name = "p521"
+version = "0.14.0-rc.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff42e4ace5424e3b6d7cb82514be89866b85af87015f80341e37dcc21a66ce6e"
+dependencies = [
+ "base16ct 1.0.0",
+ "ecdsa 0.17.0",
+ "elliptic-curve 0.14.1",
+ "primefield",
+ "primeorder 0.14.0",
+ "sha2 0.11.0",
+]
+
+[[package]]
+name = "pageant"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5"
+dependencies = [
+ "base16ct 1.0.0",
+ "byteorder",
+ "bytes",
+ "delegate",
+ "futures",
+ "log",
+ "rand 0.10.1",
+ "sha2 0.11.0",
+ "thiserror 2.0.18",
+ "tokio",
+ "windows",
+ "windows-strings",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -4532,6 +5033,15 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "password-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
+dependencies = [
+ "phc",
+]
+
[[package]]
name = "paste"
version = "1.0.15"
@@ -4554,21 +5064,40 @@ dependencies = [
"hmac 0.12.1",
]
+[[package]]
+name = "pbkdf2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
+dependencies = [
+ "digest 0.11.3",
+ "hmac 0.13.0",
+]
+
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
- "base64 0.22.1",
- "serde_core",
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
]
[[package]]
name = "pem-rfc7468"
-version = "0.7.0"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
dependencies = [
"base64ct",
]
@@ -4588,6 +5117,16 @@ dependencies = [
"serde",
]
+[[package]]
+name = "phc"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
+dependencies = [
+ "base64ct",
+ "ctutils",
+]
+
[[package]]
name = "phf"
version = "0.11.3"
@@ -4662,9 +5201,19 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
- "der",
- "pkcs8",
- "spki",
+ "der 0.7.10",
+ "pkcs8 0.10.2",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs1"
+version = "0.8.0-rc.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
+dependencies = [
+ "der 0.8.0",
+ "spki 0.8.0",
]
[[package]]
@@ -4673,13 +5222,30 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6"
dependencies = [
- "aes",
- "cbc",
- "der",
- "pbkdf2",
- "scrypt",
+ "aes 0.8.4",
+ "cbc 0.1.2",
+ "der 0.7.10",
+ "pbkdf2 0.12.2",
+ "scrypt 0.11.0",
"sha2 0.10.9",
- "spki",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs5"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca"
+dependencies = [
+ "aes 0.9.1",
+ "aes-gcm 0.11.0",
+ "cbc 0.2.1",
+ "der 0.8.0",
+ "pbkdf2 0.13.0",
+ "rand_core 0.10.1",
+ "scrypt 0.12.0",
+ "sha2 0.11.0",
+ "spki 0.8.0",
]
[[package]]
@@ -4688,10 +5254,22 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
- "der",
- "pkcs5",
+ "der 0.7.10",
+ "pkcs5 0.7.1",
"rand_core 0.6.4",
- "spki",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
+dependencies = [
+ "der 0.8.0",
+ "pkcs5 0.8.1",
+ "rand_core 0.10.1",
+ "spki 0.8.0",
]
[[package]]
@@ -4751,6 +5329,17 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "poly1305"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e"
+dependencies = [
+ "cpufeatures 0.3.0",
+ "universal-hash 0.6.1",
+ "zeroize",
+]
+
[[package]]
name = "polyval"
version = "0.6.2"
@@ -4760,7 +5349,18 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
- "universal-hash",
+ "universal-hash 0.5.1",
+]
+
+[[package]]
+name = "polyval"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede"
+dependencies = [
+ "cpubits",
+ "cpufeatures 0.3.0",
+ "universal-hash 0.6.1",
]
[[package]]
@@ -4803,13 +5403,40 @@ dependencies = [
"yansi",
]
+[[package]]
+name = "primefield"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
+dependencies = [
+ "crypto-bigint 0.7.5",
+ "crypto-common 0.2.2",
+ "ff 0.14.0",
+ "rand_core 0.10.1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
- "elliptic-curve",
+ "elliptic-curve 0.13.8",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
+dependencies = [
+ "elliptic-curve 0.14.1",
+ "once_cell",
+ "primefield",
+ "serdect",
+ "wnaf",
]
[[package]]
@@ -5366,6 +5993,16 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "rfc6979"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
+dependencies = [
+ "crypto-bigint 0.7.5",
+ "hmac 0.13.0",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -5429,16 +6066,151 @@ dependencies = [
"num-bigint-dig",
"num-integer",
"num-traits",
- "pkcs1",
- "pkcs8",
+ "pkcs1 0.7.5",
+ "pkcs8 0.10.2",
"rand_core 0.6.4",
"sha2 0.10.9",
- "signature",
- "spki",
+ "signature 2.2.0",
+ "spki 0.7.3",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rsa"
+version = "0.10.0-rc.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
+dependencies = [
+ "const-oid 0.10.2",
+ "crypto-bigint 0.7.5",
+ "crypto-primes",
+ "digest 0.11.3",
+ "pkcs1 0.8.0-rc.4",
+ "pkcs8 0.11.0",
+ "rand_core 0.10.1",
+ "sha2 0.11.0",
+ "signature 3.0.0",
+ "spki 0.8.0",
+ "zeroize",
+]
+
+[[package]]
+name = "russh"
+version = "0.62.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20d2039c3e50abb1ec3bdadea27098679da12a33befa9ab4e62adad9a13ebcb2"
+dependencies = [
+ "aes 0.9.1",
+ "aws-lc-rs",
+ "bitflags 2.13.0",
+ "block-padding 0.4.2",
+ "byteorder",
+ "bytes",
+ "cbc 0.2.1",
+ "cipher 0.5.2",
+ "crypto-bigint 0.7.5",
+ "ctr 0.10.1",
+ "curve25519-dalek 5.0.0-rc.1",
+ "data-encoding",
+ "delegate",
+ "der 0.8.0",
+ "digest 0.11.3",
+ "ecdsa 0.17.0",
+ "ed25519-dalek 3.0.0-rc.1",
+ "elliptic-curve 0.14.1",
+ "enum_dispatch",
+ "flate2",
+ "futures",
+ "generic-array 1.4.3",
+ "getrandom 0.4.3",
+ "ghash 0.6.0",
+ "hex-literal",
+ "hmac 0.13.0",
+ "inout 0.2.2",
+ "internal-russh-num-bigint",
+ "keccak",
+ "log",
+ "md5",
+ "ml-kem",
+ "module-lattice",
+ "num-bigint",
+ "p256 0.14.0-rc.15",
+ "p384 0.14.0-rc.15",
+ "p521 0.14.0-rc.15",
+ "pageant",
+ "pbkdf2 0.13.0",
+ "pkcs1 0.8.0-rc.4",
+ "pkcs5 0.8.1",
+ "pkcs8 0.11.0",
+ "polyval 0.7.1",
+ "rand 0.10.1",
+ "rand_core 0.10.1",
+ "rsa 0.10.0-rc.18",
+ "russh-cryptovec",
+ "russh-util",
+ "salsa20 0.11.0",
+ "scrypt 0.12.0",
+ "sec1 0.8.1",
+ "sha1 0.11.0",
+ "sha2 0.11.0",
+ "sha3",
+ "signature 3.0.0",
+ "spki 0.8.0",
+ "ssh-encoding",
+ "ssh-key",
"subtle",
+ "thiserror 2.0.18",
+ "tokio",
+ "typenum",
+ "universal-hash 0.6.1",
"zeroize",
]
+[[package]]
+name = "russh-cryptovec"
+version = "0.62.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438"
+dependencies = [
+ "log",
+ "nix 0.31.3",
+ "ssh-encoding",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "russh-sftp"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9"
+dependencies = [
+ "bitflags 2.13.0",
+ "bytes",
+ "chrono",
+ "dashmap",
+ "gloo-timers",
+ "log",
+ "serde",
+ "serde_bytes",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "russh-util"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6"
+dependencies = [
+ "chrono",
+ "tokio",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
[[package]]
name = "rust-embed"
version = "8.11.0"
@@ -5622,7 +6394,17 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [
- "cipher",
+ "cipher 0.4.4",
+]
+
+[[package]]
+name = "salsa20"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c"
+dependencies = [
+ "cfg-if",
+ "cipher 0.5.2",
]
[[package]]
@@ -5679,11 +6461,23 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
- "pbkdf2",
- "salsa20",
+ "pbkdf2 0.12.2",
+ "salsa20 0.10.2",
"sha2 0.10.9",
]
+[[package]]
+name = "scrypt"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
+dependencies = [
+ "cfg-if",
+ "pbkdf2 0.13.0",
+ "salsa20 0.11.0",
+ "sha2 0.11.0",
+]
+
[[package]]
name = "sea-bae"
version = "0.2.1"
@@ -5872,10 +6666,24 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
- "base16ct",
- "der",
- "generic-array",
- "pkcs8",
+ "base16ct 0.2.0",
+ "der 0.7.10",
+ "generic-array 0.14.7",
+ "pkcs8 0.10.2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "sec1"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
+dependencies = [
+ "base16ct 1.0.0",
+ "ctutils",
+ "der 0.8.0",
+ "hybrid-array",
"subtle",
"zeroize",
]
@@ -6082,6 +6890,16 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "serdect"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
+dependencies = [
+ "base16ct 1.0.0",
+ "serde",
+]
+
[[package]]
name = "sha1"
version = "0.10.6"
@@ -6132,6 +6950,16 @@ dependencies = [
"digest 0.11.3",
]
+[[package]]
+name = "sha3"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
+dependencies = [
+ "digest 0.11.3",
+ "keccak",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -6167,6 +6995,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "signature"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
+dependencies = [
+ "digest 0.11.3",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -6273,7 +7111,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
- "der",
+ "der 0.7.10",
+]
+
+[[package]]
+name = "spki"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
+dependencies = [
+ "base64ct",
+ "der 0.8.0",
]
[[package]]
@@ -6383,7 +7231,7 @@ dependencies = [
"either",
"futures-core",
"futures-util",
- "generic-array",
+ "generic-array 0.14.7",
"log",
"percent-encoding",
"rust_decimal",
@@ -6463,6 +7311,65 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "ssh-cipher"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
+dependencies = [
+ "aead 0.6.1",
+ "aes 0.9.1",
+ "aes-gcm 0.11.0",
+ "chacha20",
+ "cipher 0.5.2",
+ "ctutils",
+ "des",
+ "poly1305",
+ "ssh-encoding",
+ "zeroize",
+]
+
+[[package]]
+name = "ssh-encoding"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e"
+dependencies = [
+ "base64ct",
+ "bytes",
+ "crypto-bigint 0.7.5",
+ "ctutils",
+ "digest 0.11.3",
+ "pem-rfc7468 1.0.0",
+ "zeroize",
+]
+
+[[package]]
+name = "ssh-key"
+version = "0.7.0-rc.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3"
+dependencies = [
+ "argon2 0.6.0-rc.8",
+ "bcrypt-pbkdf",
+ "ctutils",
+ "ed25519-dalek 3.0.0-rc.1",
+ "hex",
+ "hmac 0.13.0",
+ "p256 0.14.0-rc.15",
+ "p384 0.14.0-rc.15",
+ "p521 0.14.0-rc.15",
+ "rand_core 0.10.1",
+ "rsa 0.10.0-rc.18",
+ "sec1 0.8.1",
+ "sha1 0.11.0",
+ "sha2 0.11.0",
+ "signature 3.0.0",
+ "ssh-cipher",
+ "ssh-encoding",
+ "zeroize",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -7294,6 +8201,16 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "universal-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
+dependencies = [
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -8050,6 +8967,17 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "wnaf"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
+dependencies = [
+ "ff 0.14.0",
+ "group 0.14.0",
+ "hybrid-array",
+]
+
[[package]]
name = "writeable"
version = "0.6.3"
@@ -8072,10 +9000,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid 0.9.6",
- "der",
+ "der 0.7.10",
"sha1 0.10.6",
- "signature",
- "spki",
+ "signature 2.2.0",
+ "spki 0.7.3",
"tls_codec",
]
diff --git a/Cargo.toml b/Cargo.toml
index edc7899c1..3ba61d8aa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -129,6 +129,8 @@ serde_repr = "0.1.20"
secrecy = "0.10"
sha1 = "0.11"
sha2 = "0.11"
+russh = "0.62.1"
+russh-sftp = "2.3.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "fs", "io-util"] }
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] }
tokio-util = { version = "0.7", features = ["io", "io-util"] }
diff --git a/frontend-panel/src/components/admin/StoragePolicyDialog.tsx b/frontend-panel/src/components/admin/StoragePolicyDialog.tsx
index 87a6671d4..d9cb11470 100644
--- a/frontend-panel/src/components/admin/StoragePolicyDialog.tsx
+++ b/frontend-panel/src/components/admin/StoragePolicyDialog.tsx
@@ -12,6 +12,7 @@ import {
supportsOneDrivePolicyOptions,
supportsRemoteNodeBinding,
supportsSavedConnectionTest,
+ supportsStaticSecretConnection,
supportsStorageNativeProcessing,
} from "@/components/admin/storage-policy-dialog/descriptorPredicates";
import type { PolicyFormData } from "@/components/admin/storage-policy-dialog/formTypes";
@@ -192,6 +193,9 @@ function useStoragePolicyDialogContent({
const canUseObjectStorageConnection = supportsObjectStorageConnection(
storageDriverDescriptor,
);
+ const canUseStaticSecretConnection = supportsStaticSecretConnection(
+ storageDriverDescriptor,
+ );
const canUseRemoteNodeBinding = supportsRemoteNodeBinding(
storageDriverDescriptor,
);
@@ -286,7 +290,7 @@ function useStoragePolicyDialogContent({
? t("onedrive_client_secret_required")
: null;
const createEndpointError =
- canUseObjectStorageConnection && !form.endpoint.trim()
+ canUseStaticSecretConnection && !form.endpoint.trim()
? isCreateMode
? createStep === 1 && createStepTouched
? t("policy_wizard_endpoint_required")
@@ -410,13 +414,15 @@ function useStoragePolicyDialogContent({
},
]
: []),
- ...(canUseObjectStorageConnection
+ ...(canUseStaticSecretConnection
? [
{
label: t("endpoint"),
value: form.endpoint || t("policy_wizard_default_endpoint"),
},
- { label: t("bucket"), value: form.bucket || "—" },
+ ...(canUseObjectStorageConnection
+ ? [{ label: t("bucket"), value: form.bucket || "—" }]
+ : []),
...(canUseObjectStorageTransferStrategy
? [
{
diff --git a/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.test.ts b/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.test.ts
index 27fec5d41..de7c8aab0 100644
--- a/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.test.ts
+++ b/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.test.ts
@@ -5,6 +5,7 @@ describe("policyPresentation", () => {
it("returns distinct badge classes for every storage driver", () => {
expect(getPolicyDriverBadgeClass("local")).toContain("text-emerald-600");
expect(getPolicyDriverBadgeClass("s3")).toContain("text-blue-600");
+ expect(getPolicyDriverBadgeClass("sftp")).toContain("text-violet-700");
expect(getPolicyDriverBadgeClass("tencent_cos")).toContain("text-cyan-700");
expect(getPolicyDriverBadgeClass("azure_blob")).toContain("text-sky-700");
expect(getPolicyDriverBadgeClass("remote")).toContain("text-amber-600");
diff --git a/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts b/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts
index f365f6bf3..af121806f 100644
--- a/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts
+++ b/frontend-panel/src/components/admin/admin-policies-page/policyPresentation.ts
@@ -11,6 +11,7 @@ const POLICY_DRIVER_BADGE_CLASSES = {
remote:
"border-amber-500/60 bg-amber-500/10 text-amber-600 dark:text-amber-300",
s3: "border-blue-500/60 bg-blue-500/10 text-blue-600 dark:text-blue-300",
+ sftp: "border-violet-500/60 bg-violet-500/10 text-violet-700 dark:text-violet-300",
tencent_cos:
"border-cyan-500/60 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300",
} satisfies Record;
diff --git a/frontend-panel/src/components/admin/storage-policy-dialog/StoragePolicyCreateWizard.tsx b/frontend-panel/src/components/admin/storage-policy-dialog/StoragePolicyCreateWizard.tsx
index 2b5cadd63..f93104713 100644
--- a/frontend-panel/src/components/admin/storage-policy-dialog/StoragePolicyCreateWizard.tsx
+++ b/frontend-panel/src/components/admin/storage-policy-dialog/StoragePolicyCreateWizard.tsx
@@ -41,6 +41,7 @@ import {
supportsObjectStorageTransferStrategy,
supportsOneDrivePolicyOptions,
supportsRemoteNodeBinding,
+ supportsStaticSecretConnection,
supportsStorageNativeProcessing,
} from "./descriptorPredicates";
import type { PolicyFormData } from "./formTypes";
@@ -428,7 +429,7 @@ function ConnectionStep({
remoteStorageTargetsLoading,
t,
}: ConnectionStepProps) {
- const canUseObjectStorageConnection = supportsObjectStorageConnection(
+ const canUseStaticSecretConnection = supportsStaticSecretConnection(
storageDriverDescriptor,
);
const canUseRemoteNodeBinding = supportsRemoteNodeBinding(
@@ -459,7 +460,7 @@ function ConnectionStep({
t={t}
onFieldChange={onFieldChange}
/>
- {canUseObjectStorageConnection ? (
+ {canUseStaticSecretConnection ? (
- {canUseObjectStorageConnection ? (
+ {canUseStaticSecretConnection ? (
@@ -65,22 +66,26 @@ export function ObjectStorageConnectionFields({
) : null}
-
-
-
onFieldChange("bucket", e.target.value)}
- aria-invalid={showCreateValidation && bucketError ? true : undefined}
- className={ADMIN_CONTROL_HEIGHT_CLASS}
- required
- />
- {showCreateValidation && bucketError ? (
-
{bucketError}
- ) : null}
-
+ {hasBucketField ? (
+
+
+
onFieldChange("bucket", e.target.value)}
+ aria-invalid={
+ showCreateValidation && bucketError ? true : undefined
+ }
+ className={ADMIN_CONTROL_HEIGHT_CLASS}
+ required={bucketField.required}
+ />
+ {showCreateValidation && bucketError ? (
+
{bucketError}
+ ) : null}
+
+ ) : null}
{showPathStyleField ? (
field.scope === "connection" && field.name === "endpoint",
)?.invalid_protocol_message_key ?? "s3_endpoint_protocol_required_error";
+ const allowedProtocols = allowedEndpointProtocols(descriptor);
+ if (!hasEndpointUrlScheme(trimmedEndpoint)) {
+ return allowedProtocols.includes("sftp:")
+ ? null
+ : t(endpointProtocolMessage);
+ }
+
let endpointUrl: URL;
try {
endpointUrl = new URL(trimmedEndpoint);
@@ -264,7 +289,7 @@ export function getEndpointValidationMessage(
return t(endpointProtocolMessage);
}
- if (endpointUrl.protocol !== "http:" && endpointUrl.protocol !== "https:") {
+ if (!allowedProtocols.includes(endpointUrl.protocol)) {
return t(endpointProtocolMessage);
}
@@ -281,6 +306,16 @@ function shouldUseObjectStorageConnection(
: hasObjectStorageConnectionFields(form, editingPolicy);
}
+function shouldUseStaticSecretConnection(
+ form: PolicyFormData,
+ descriptor?: StorageConnectorDescriptor | null,
+ editingPolicy?: StoragePolicy | null,
+) {
+ return descriptor
+ ? supportsStaticSecretConnection(descriptor)
+ : hasStaticSecretConnectionFields(form, editingPolicy);
+}
+
function shouldUseMicrosoftGraphConfig(
form: PolicyFormData,
descriptor?: StorageConnectorDescriptor | null,
@@ -313,10 +348,41 @@ function hasObjectStorageConnectionFields(
);
}
+function hasStaticSecretConnectionFields(
+ form: PolicyFormData,
+ editingPolicy?: StoragePolicy | null,
+) {
+ return Boolean(
+ hasObjectStorageConnectionFields(form, editingPolicy) ||
+ hasText(form.endpoint) ||
+ hasText(editingPolicy?.endpoint),
+ );
+}
+
function hasEndpointUrlScheme(endpoint?: string | null) {
return /^[a-z][a-z0-9+.-]*:/i.test(endpoint?.trim() ?? "");
}
+function allowedEndpointProtocols(
+ descriptor?: StorageConnectorDescriptor | null,
+) {
+ return descriptor?.driver_type === "sftp" ? ["sftp:"] : ["http:", "https:"];
+}
+
+function shouldTrimConnectionField(
+ descriptor: StorageConnectorDescriptor | null | undefined,
+ fieldName: string,
+) {
+ return (
+ descriptor?.fields.some(
+ (field) =>
+ field.scope === "connection" &&
+ field.name === fieldName &&
+ field.trim_on_blur === true,
+ ) ?? false
+ );
+}
+
function hasExplicitMicrosoftGraphFields(form: PolicyFormData) {
const microsoftGraph = microsoftGraphCredentials(form);
const tenant = form.onedrive_tenant?.trim();
diff --git a/frontend-panel/src/components/admin/storage-policy-dialog/descriptorPredicates.ts b/frontend-panel/src/components/admin/storage-policy-dialog/descriptorPredicates.ts
index 3c5af6585..9cf7c5b22 100644
--- a/frontend-panel/src/components/admin/storage-policy-dialog/descriptorPredicates.ts
+++ b/frontend-panel/src/components/admin/storage-policy-dialog/descriptorPredicates.ts
@@ -46,6 +46,17 @@ export function supportsObjectStorageConnection(
);
}
+export function supportsStaticSecretConnection(
+ descriptor: StorageConnectorDescriptor | null | undefined,
+) {
+ return (
+ descriptor?.credential_mode === "static_secret" &&
+ descriptorHasConnectionField(descriptor, "endpoint") &&
+ descriptorHasConnectionField(descriptor, "access_key") &&
+ descriptorHasConnectionField(descriptor, "secret_key")
+ );
+}
+
export function supportsRemoteNodeBinding(
descriptor: StorageConnectorDescriptor | null | undefined,
) {
diff --git a/frontend-panel/src/i18n/locales/en/admin/policies.json b/frontend-panel/src/i18n/locales/en/admin/policies.json
index cabc2a2d8..69c30a3f5 100644
--- a/frontend-panel/src/i18n/locales/en/admin/policies.json
+++ b/frontend-panel/src/i18n/locales/en/admin/policies.json
@@ -88,9 +88,11 @@
"s3_endpoint_hint": "Enter the S3-compatible API endpoint and keep the bucket in the bucket field. Providers differ on path-style support, so test the connection before saving.",
"cos_endpoint_hint": "For Tencent COS, use the bucket domain such as https://.cos..myqcloud.com; enter the bucket separately, for example .",
"azure_blob_endpoint_hint": "Enter the Azure Blob service endpoint, for example https://.blob.core.windows.net. Use the bucket field for the container name.",
+ "sftp_endpoint_hint": "Enter an SFTP server endpoint such as sftp://example.com:22. Put the remote root directory in base path.",
"policy_wizard_endpoint_required": "Endpoint is required.",
"s3_endpoint_protocol_required_error": "S3 endpoint must include http:// or https://.",
"azure_blob_endpoint_protocol_required_error": "Azure Blob endpoint must include http:// or https://.",
+ "sftp_endpoint_protocol_required_error": "SFTP endpoint must use sftp:// or omit the scheme.",
"download_strategy_relay_stream": "Server Relay Download",
"download_strategy_relay_stream_desc": "AsterDrive fetches the object from the storage backend and streams it back to the browser. Use this when you need the app node to fully control response headers, same-origin delivery, or downstream network policy.",
"download_strategy_presigned": "Presigned Redirect",
@@ -160,6 +162,7 @@
"driver_type_local": "Local",
"driver_type_remote": "Remote",
"driver_type_s3": "S3",
+ "driver_type_sftp": "SFTP",
"driver_type_tencent_cos": "Tencent COS",
"driver_type_azure_blob": "Azure Blob",
"driver_type_onedrive": "OneDrive",
@@ -177,6 +180,8 @@
"policy_wizard_step_connection_title": "Configure Connection",
"policy_wizard_step_connection_desc": "Review the connection settings required by this storage backend.",
"policy_wizard_step_object_storage_connection_desc": "Set the object-storage endpoint, bucket, and credentials.",
+ "policy_wizard_step_sftp_title": "Configure SFTP",
+ "policy_wizard_step_sftp_desc": "Set the SFTP endpoint, SSH username, password, and remote root path.",
"policy_wizard_step_tencent_cos_connection_desc": "Set the Tencent COS bucket domain, bucket, and credentials.",
"policy_wizard_step_azure_blob_connection_desc": "Set the Azure Blob endpoint, container name, storage account name, and key.",
"policy_wizard_step_onedrive_title": "Configure Microsoft Graph",
@@ -192,6 +197,7 @@
"policy_wizard_local_storage_desc": "Store files on the AsterDrive server filesystem. Simple setup and suitable for single-node deployments.",
"policy_wizard_remote_storage_desc": "Store files on another AsterDrive node over the internal remote storage protocol. Good for tiered or federated deployments.",
"policy_wizard_s3_storage_desc": "Store files in an S3-compatible object bucket such as Amazon S3, MinIO, or RustFS.",
+ "policy_wizard_sftp_storage_desc": "Store files on an SFTP server through server-side streaming. Browsers never connect to SFTP directly.",
"policy_wizard_tencent_cos_storage_desc": "Store files in Tencent Cloud COS and enable storage-native document preview through COS CI.",
"policy_wizard_azure_blob_storage_desc": "Store files in an Azure Blob container with native SAS URLs and Block Blob multipart uploads.",
"policy_wizard_onedrive_storage_desc": "Store files in OneDrive, SharePoint document libraries, or Microsoft 365 group drives through Microsoft Graph.",
@@ -224,6 +230,7 @@
"policy_wizard_onedrive_helper": "Enter the Microsoft application client ID while creating the policy. After saving, the dialog switches to editing so you can authorize Microsoft Graph directly. Choose Global for microsoft.com tenants and China for 21Vianet tenants.",
"policy_wizard_remote_helper": "The selected remote node handles network transport. This policy controls the remote path prefix, upload mode, and size limits.",
"policy_wizard_local_helper": "Use a relative or absolute path. Leave it empty to fall back to the application's default data directory.",
+ "policy_wizard_sftp_helper": "AsterDrive streams uploads and downloads through the app server, then reads and writes files over SFTP using the configured SSH credentials.",
"policy_wizard_remote_rules_helper": "Remote storage does not use per-policy endpoint or credentials. Bind a remote node, choose pure stream relay or presigned direct upload, then configure upload limits and default-policy behavior.",
"policy_wizard_onedrive_rules_helper": "OneDrive upload behavior is managed by Microsoft Graph upload sessions. Configure size limits and default-policy behavior here; OAuth authorization happens after the policy is saved.",
"policy_wizard_summary_title": "Policy Summary",
@@ -245,6 +252,7 @@
"policy_edit_context_title": "Current Policy",
"policy_edit_context_local_desc": "Local policies write directly to the server filesystem. Adjust paths and upload rules below.",
"policy_edit_context_object_storage_desc": "Test object storage connections before saving. Blank secret fields keep the current credentials.",
+ "policy_edit_context_sftp_desc": "SFTP policies stream through the app server and write to the configured remote root. Blank password fields keep the current credential.",
"policy_edit_context_azure_blob_desc": "Azure Blob policies use the storage account key to issue short-lived SAS URLs. Test the connection before saving; blank secret fields keep the current credentials.",
"policy_edit_context_remote_desc": "Remote policies transfer through the bound node. Adjust paths, node binding, and upload rules below.",
"policy_edit_context_onedrive_desc": "OneDrive policies use saved Microsoft Graph OAuth credentials. Save target changes before starting authorization.",
diff --git a/frontend-panel/src/i18n/locales/zh/admin/policies.json b/frontend-panel/src/i18n/locales/zh/admin/policies.json
index c216fe6ad..e0ba6bf07 100644
--- a/frontend-panel/src/i18n/locales/zh/admin/policies.json
+++ b/frontend-panel/src/i18n/locales/zh/admin/policies.json
@@ -88,9 +88,11 @@
"s3_endpoint_hint": "填写兼容 S3 的 API endpoint,bucket 请单独填写。不同厂商对 path-style 的要求不同,保存前请先测试连接。",
"cos_endpoint_hint": "腾讯云 COS 请填写 bucket 域名,例如 https://.cos..myqcloud.com;bucket 请单独填写,例如 。",
"azure_blob_endpoint_hint": "填写 Azure Blob 服务 endpoint,例如 https://.blob.core.windows.net;bucket 字段填写容器名称。",
+ "sftp_endpoint_hint": "填写 SFTP 服务器 endpoint,例如 sftp://example.com:22;远程根目录请填写在基础路径里。",
"policy_wizard_endpoint_required": "必须填写端点。",
"s3_endpoint_protocol_required_error": "S3 endpoint 必须包含 http:// 或 https://。",
"azure_blob_endpoint_protocol_required_error": "Azure Blob endpoint 必须包含 http:// 或 https://。",
+ "sftp_endpoint_protocol_required_error": "SFTP endpoint 必须使用 sftp://,或省略协议。",
"download_strategy_relay_stream": "服务端中继下载",
"download_strategy_relay_stream_desc": "AsterDrive 先从存储后端拉取对象,再把内容流式回传给浏览器。适合需要由应用节点完全控制响应头、同源下载行为或下游网络策略的场景。",
"download_strategy_presigned": "Presigned 重定向",
@@ -160,6 +162,7 @@
"driver_type_local": "本机",
"driver_type_remote": "远程节点",
"driver_type_s3": "S3",
+ "driver_type_sftp": "SFTP",
"driver_type_tencent_cos": "腾讯云 COS",
"driver_type_azure_blob": "Azure Blob",
"driver_type_onedrive": "OneDrive",
@@ -177,6 +180,8 @@
"policy_wizard_step_connection_title": "配置连接",
"policy_wizard_step_connection_desc": "检查当前存储后端需要的连接配置。",
"policy_wizard_step_object_storage_connection_desc": "填写对象存储 endpoint、bucket 和访问凭证。",
+ "policy_wizard_step_sftp_title": "配置 SFTP",
+ "policy_wizard_step_sftp_desc": "填写 SFTP endpoint、SSH 用户名、密码和远程根目录。",
"policy_wizard_step_tencent_cos_connection_desc": "填写腾讯云 COS bucket 域名、bucket 和访问凭证。",
"policy_wizard_step_azure_blob_connection_desc": "填写 Azure Blob endpoint、容器名称、存储账户名和密钥。",
"policy_wizard_step_onedrive_title": "配置 Microsoft Graph",
@@ -192,6 +197,7 @@
"policy_wizard_local_storage_desc": "文件直接落在 AsterDrive 所在服务器的文件系统上,配置简单,适合单机部署。",
"policy_wizard_remote_storage_desc": "通过内部远程存储协议把文件写入另一台 AsterDrive 节点,适合分层或多节点部署。",
"policy_wizard_s3_storage_desc": "文件存入兼容 S3 的对象存储,例如 Amazon S3、MinIO 或 RustFS。",
+ "policy_wizard_sftp_storage_desc": "通过服务端流式中继把文件存入 SFTP 服务器,浏览器不会直接连接 SFTP。",
"policy_wizard_tencent_cos_storage_desc": "文件存入腾讯云 COS,并可使用 COS 数据万象进行原生文档预览。",
"policy_wizard_azure_blob_storage_desc": "文件存入 Azure Blob 容器,使用原生 SAS URL 和 Block Blob 分块上传。",
"policy_wizard_onedrive_storage_desc": "通过 Microsoft Graph 把文件存入 OneDrive、SharePoint 文档库或 Microsoft 365 组 drive。",
@@ -224,6 +230,7 @@
"policy_wizard_onedrive_helper": "创建时填写 Microsoft 应用 Client ID;保存后会直接进入编辑态授权 Microsoft Graph。microsoft.com 租户选国际版,世纪互联租户选中国版。",
"policy_wizard_remote_helper": "实际网络传输由远程节点负责,这个策略控制远端路径前缀、上传方式和大小限制。",
"policy_wizard_local_helper": "支持相对路径或绝对路径。留空时会回退到应用默认的数据目录。",
+ "policy_wizard_sftp_helper": "AsterDrive 会通过应用服务器流式中继上传和下载,再使用配置的 SSH 凭据通过 SFTP 读写文件。",
"policy_wizard_remote_rules_helper": "远程存储不使用策略级 endpoint 或凭证;这里只需绑定远程节点,并选择纯流式转发还是 Presigned 直传,再配置上传限制和默认策略。",
"policy_wizard_onedrive_rules_helper": "OneDrive 上传行为由 Microsoft Graph upload session 管理。这里配置大小限制和默认策略;OAuth 授权会在策略保存后进行。",
"policy_wizard_summary_title": "策略摘要",
@@ -245,6 +252,7 @@
"policy_edit_context_title": "当前策略",
"policy_edit_context_local_desc": "本机策略直接写入服务器文件系统;路径和上传规则在下方调整。",
"policy_edit_context_object_storage_desc": "对象存储策略保存前建议测试连接;留空密钥字段会保留现有凭证。",
+ "policy_edit_context_sftp_desc": "SFTP 策略通过应用服务器流式中继,并写入配置的远程根目录;留空密码字段会保留现有凭证。",
"policy_edit_context_azure_blob_desc": "Azure Blob 策略使用存储账户密钥签发短期 SAS URL;保存前建议测试连接,留空密钥字段会保留现有凭证。",
"policy_edit_context_remote_desc": "远程策略由绑定节点负责传输;这里调整路径、节点和上传规则。",
"policy_edit_context_onedrive_desc": "OneDrive 策略使用已保存的 Microsoft Graph OAuth 凭据。开始授权前请先保存目标配置。",
diff --git a/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx b/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx
index f55d97140..f378f73aa 100644
--- a/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx
+++ b/frontend-panel/src/pages/admin/AdminPoliciesPage.test.tsx
@@ -66,6 +66,8 @@ const mockTranslate = vi.hoisted(
return "Local";
case "driver_type_s3":
return "S3";
+ case "driver_type_sftp":
+ return "SFTP";
case "driver_type_tencent_cos":
return "Tencent COS";
case "driver_type_azure_blob":
@@ -854,6 +856,19 @@ function storageConnectorUi(driverType: string) {
icon_src: "/static/storage/onedrive.svg",
label_key: "driver_type_onedrive",
};
+ case "sftp":
+ return {
+ base_path_empty_display: "core:root",
+ base_path_placeholder: "/srv/asterdrive",
+ config_step_description_key: "policy_wizard_step_sftp_desc",
+ config_step_title_key: "policy_wizard_step_sftp_title",
+ description_key: "policy_wizard_sftp_storage_desc",
+ edit_context_key: "policy_edit_context_sftp_desc",
+ helper_key: "policy_wizard_sftp_helper",
+ icon_name: "ServerCog",
+ icon_src: null,
+ label_key: "driver_type_sftp",
+ };
default:
return {
...sharedObjectStorageUi,
@@ -996,6 +1011,30 @@ function createStorageDriverDescriptors() {
presigned_upload: true,
},
}),
+ createStorageDriverDescriptor("sftp", {
+ capabilities: {
+ ...createStorageDriverDescriptor("sftp").capabilities,
+ capacity: false,
+ list: false,
+ },
+ credential_mode: "static_secret",
+ fields: [
+ fieldDescriptor("endpoint", "connection", "text", {
+ help_key: "sftp_endpoint_hint",
+ invalid_protocol_message_key: "sftp_endpoint_protocol_required_error",
+ placeholder: "sftp://example.com:22",
+ required: true,
+ trim_on_blur: true,
+ }),
+ fieldDescriptor("access_key", "connection", "text", {
+ required: true,
+ }),
+ fieldDescriptor("secret_key", "connection", "secret", {
+ required: true,
+ }),
+ fieldDescriptor("base_path", "connection", "text"),
+ ],
+ }),
createStorageDriverDescriptor("tencent_cos", {
actions: [
{
@@ -1132,6 +1171,7 @@ function openCreateWizard(
| "local"
| "remote"
| "s3"
+ | "sftp"
| "tencent_cos"
| "azure_blob"
| "one_drive" = "local",
@@ -1143,6 +1183,8 @@ function openCreateWizard(
fireEvent.click(screen.getByRole("button", { name: /^Remote\b/ }));
} else if (driver === "s3") {
fireEvent.click(screen.getByRole("button", { name: /^S3\b/ }));
+ } else if (driver === "sftp") {
+ fireEvent.click(screen.getByRole("button", { name: /^SFTP\b/ }));
} else if (driver === "tencent_cos") {
fireEvent.click(screen.getByRole("button", { name: /Tencent COS/ }));
} else if (driver === "azure_blob") {
diff --git a/frontend-panel/src/pages/admin/AdminPoliciesPage.tsx b/frontend-panel/src/pages/admin/AdminPoliciesPage.tsx
index 6c78f3d01..0e7e173d5 100644
--- a/frontend-panel/src/pages/admin/AdminPoliciesPage.tsx
+++ b/frontend-panel/src/pages/admin/AdminPoliciesPage.tsx
@@ -24,6 +24,7 @@ import {
supportsOneDrivePolicyOptions,
supportsRemoteNodeBinding,
supportsSavedConnectionTest,
+ supportsStaticSecretConnection,
supportsStorageCredentialLifecycle,
supportsStorageNativeProcessing,
supportsStoragePolicyAction,
@@ -1069,10 +1070,13 @@ function useAdminPoliciesPageContent() {
);
const nextSupportsStorageNativeProcessing =
supportsStorageNativeProcessing(nextDriverDescriptor);
- if (supportsObjectStorageConnection(nextDriverDescriptor)) {
+ if (supportsStaticSecretConnection(nextDriverDescriptor)) {
return {
...prevWithoutS3PathStyle,
driver_type: driverType,
+ bucket: supportsObjectStorageConnection(nextDriverDescriptor)
+ ? prev.bucket
+ : "",
remote_node_id: "",
remote_storage_target_key: "",
storage_native_processing_enabled: nextSupportsStorageNativeProcessing
@@ -1237,7 +1241,7 @@ function useAdminPoliciesPageContent() {
}
if (
- supportsObjectStorageConnection(descriptor) ||
+ supportsStaticSecretConnection(descriptor) ||
supportsRemoteNodeBinding(descriptor)
) {
setValidatedConnectionKey(
@@ -1641,7 +1645,7 @@ function useAdminPoliciesPageContent() {
}
if (
- supportsObjectStorageConnection(currentStorageDriverDescriptor) &&
+ supportsStaticSecretConnection(currentStorageDriverDescriptor) &&
!form.endpoint.trim()
) {
return;
diff --git a/frontend-panel/src/services/api.generated.ts b/frontend-panel/src/services/api.generated.ts
index 6ef1066bf..902211739 100644
--- a/frontend-panel/src/services/api.generated.ts
+++ b/frontend-panel/src/services/api.generated.ts
@@ -5244,7 +5244,7 @@ export interface components {
* @description 存储驱动类型
* @enum {string}
*/
- DriverType: "local" | "s3" | "azure_blob" | "tencent_cos" | "remote" | "one_drive";
+ DriverType: "local" | "s3" | "sftp" | "azure_blob" | "tencent_cos" | "remote" | "one_drive";
/** @description Check a storage policy migration plan without creating a task. */
DryRunStoragePolicyMigrationReq: {
delete_source_after_success?: boolean;
diff --git a/src/storage/connectors/mod.rs b/src/storage/connectors/mod.rs
index 4af86cbf1..a3724bcad 100644
--- a/src/storage/connectors/mod.rs
+++ b/src/storage/connectors/mod.rs
@@ -16,6 +16,7 @@ mod models;
mod onedrive;
mod remote;
mod s3;
+mod sftp;
mod tencent_cos;
mod upload;
@@ -35,7 +36,8 @@ use crate::storage::connector_descriptor::{
StorageConnectorDescriptorProvider, StoragePolicyExecutableAction,
};
use crate::storage::drivers::{
- azure_blob::AzureBlobDriver, local::LocalDriver, s3::S3Driver, tencent_cos::TencentCosDriver,
+ azure_blob::AzureBlobDriver, local::LocalDriver, s3::S3Driver, sftp::SftpDriver,
+ tencent_cos::TencentCosDriver,
};
use crate::types::{DriverType, StorageCredentialKind, StorageCredentialProvider};
@@ -57,6 +59,7 @@ pub(crate) use models::{
use onedrive::OneDriveConnector;
use remote::RemoteConnector;
use s3::S3Connector;
+use sftp::SftpConnector;
use tencent_cos::TencentCosConnector;
pub use upload::{StorageConnectorChunkedCompletion, StorageConnectorUploadTransport};
@@ -317,6 +320,7 @@ struct StorageConnectorRegistration {
enum BuiltinStorageConnector {
Local,
S3,
+ Sftp,
AzureBlob,
TencentCos,
Remote,
@@ -328,6 +332,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::storage_connector_descriptor(),
Self::S3 => S3Connector::storage_connector_descriptor(),
+ Self::Sftp => SftpConnector::storage_connector_descriptor(),
Self::AzureBlob => AzureBlobConnector::storage_connector_descriptor(),
Self::TencentCos => TencentCosConnector::storage_connector_descriptor(),
Self::Remote => RemoteConnector::storage_connector_descriptor(),
@@ -345,6 +350,9 @@ impl BuiltinStorageConnector {
common::normalize_policy_connection_for::(db, input).await
}
Self::S3 => common::normalize_policy_connection_for::(db, input).await,
+ Self::Sftp => {
+ common::normalize_policy_connection_for::(db, input).await
+ }
Self::AzureBlob => {
common::normalize_policy_connection_for::(db, input).await
}
@@ -370,6 +378,7 @@ impl BuiltinStorageConnector {
LocalConnector::prepare_connection_for_storage(input, application_config)
}
Self::S3 => S3Connector::prepare_connection_for_storage(input, application_config),
+ Self::Sftp => SftpConnector::prepare_connection_for_storage(input, application_config),
Self::AzureBlob => {
AzureBlobConnector::prepare_connection_for_storage(input, application_config)
}
@@ -396,6 +405,7 @@ impl BuiltinStorageConnector {
LocalConnector::validate_policy_options(db, remote_node_id, options).await
}
Self::S3 => S3Connector::validate_policy_options(db, remote_node_id, options).await,
+ Self::Sftp => SftpConnector::validate_policy_options(db, remote_node_id, options).await,
Self::AzureBlob => {
AzureBlobConnector::validate_policy_options(db, remote_node_id, options).await
}
@@ -440,6 +450,16 @@ impl BuiltinStorageConnector {
)
.await
}
+ Self::Sftp => {
+ SftpConnector::persist_application_config(
+ db,
+ encryption_key,
+ policy_id,
+ options,
+ application_config,
+ )
+ .await
+ }
Self::AzureBlob => {
AzureBlobConnector::persist_application_config(
db,
@@ -491,6 +511,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::test_draft_connection(state, input).await,
Self::S3 => S3Connector::test_draft_connection(state, input).await,
+ Self::Sftp => SftpConnector::test_draft_connection(state, input).await,
Self::AzureBlob => AzureBlobConnector::test_draft_connection(state, input).await,
Self::TencentCos => TencentCosConnector::test_draft_connection(state, input).await,
Self::Remote => RemoteConnector::test_draft_connection(state, input).await,
@@ -506,6 +527,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::test_saved_connection(state, policy).await,
Self::S3 => S3Connector::test_saved_connection(state, policy).await,
+ Self::Sftp => SftpConnector::test_saved_connection(state, policy).await,
Self::AzureBlob => AzureBlobConnector::test_saved_connection(state, policy).await,
Self::TencentCos => TencentCosConnector::test_saved_connection(state, policy).await,
Self::Remote => RemoteConnector::test_saved_connection(state, policy).await,
@@ -522,6 +544,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::execute_saved_action(state, policy, action).await,
Self::S3 => S3Connector::execute_saved_action(state, policy, action).await,
+ Self::Sftp => SftpConnector::execute_saved_action(state, policy, action).await,
Self::AzureBlob => {
AzureBlobConnector::execute_saved_action(state, policy, action).await
}
@@ -541,6 +564,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::execute_draft_action(state, input).await,
Self::S3 => S3Connector::execute_draft_action(state, input).await,
+ Self::Sftp => SftpConnector::execute_draft_action(state, input).await,
Self::AzureBlob => AzureBlobConnector::execute_draft_action(state, input).await,
Self::TencentCos => TencentCosConnector::execute_draft_action(state, input).await,
Self::Remote => RemoteConnector::execute_draft_action(state, input).await,
@@ -552,6 +576,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::upload_transport(policy),
Self::S3 => S3Connector::upload_transport(policy),
+ Self::Sftp => SftpConnector::upload_transport(policy),
Self::AzureBlob => AzureBlobConnector::upload_transport(policy),
Self::TencentCos => TencentCosConnector::upload_transport(policy),
Self::Remote => RemoteConnector::upload_transport(policy),
@@ -563,6 +588,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::presigned_download_enabled(policy),
Self::S3 => S3Connector::presigned_download_enabled(policy),
+ Self::Sftp => SftpConnector::presigned_download_enabled(policy),
Self::AzureBlob => AzureBlobConnector::presigned_download_enabled(policy),
Self::TencentCos => TencentCosConnector::presigned_download_enabled(policy),
Self::Remote => RemoteConnector::presigned_download_enabled(policy),
@@ -574,6 +600,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::runtime_credential_requirement(),
Self::S3 => S3Connector::runtime_credential_requirement(),
+ Self::Sftp => SftpConnector::runtime_credential_requirement(),
Self::AzureBlob => AzureBlobConnector::runtime_credential_requirement(),
Self::TencentCos => TencentCosConnector::runtime_credential_requirement(),
Self::Remote => RemoteConnector::runtime_credential_requirement(),
@@ -593,6 +620,9 @@ impl BuiltinStorageConnector {
LocalConnector::load_runtime_credential(db, config, policy, credential).await
}
Self::S3 => S3Connector::load_runtime_credential(db, config, policy, credential).await,
+ Self::Sftp => {
+ SftpConnector::load_runtime_credential(db, config, policy, credential).await
+ }
Self::AzureBlob => {
AzureBlobConnector::load_runtime_credential(db, config, policy, credential).await
}
@@ -616,6 +646,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => LocalConnector::build_authorized_driver(policy, credential),
Self::S3 => S3Connector::build_authorized_driver(policy, credential),
+ Self::Sftp => SftpConnector::build_authorized_driver(policy, credential),
Self::AzureBlob => AzureBlobConnector::build_authorized_driver(policy, credential),
Self::TencentCos => TencentCosConnector::build_authorized_driver(policy, credential),
Self::Remote => RemoteConnector::build_authorized_driver(policy, credential),
@@ -635,6 +666,7 @@ impl BuiltinStorageConnector {
LocalConnector::validate_credential(db, config, policy, credential).await
}
Self::S3 => S3Connector::validate_credential(db, config, policy, credential).await,
+ Self::Sftp => SftpConnector::validate_credential(db, config, policy, credential).await,
Self::AzureBlob => {
AzureBlobConnector::validate_credential(db, config, policy, credential).await
}
@@ -658,7 +690,7 @@ impl BuiltinStorageConnector {
match self {
Self::Remote => RemoteConnector::cleanup_snapshot_for_policy(state, policy).await,
Self::OneDrive => OneDriveConnector::cleanup_snapshot_for_policy(state, policy).await,
- Self::Local | Self::S3 | Self::AzureBlob | Self::TencentCos => Ok(None),
+ Self::Local | Self::S3 | Self::Sftp | Self::AzureBlob | Self::TencentCos => Ok(None),
}
}
@@ -671,6 +703,7 @@ impl BuiltinStorageConnector {
match self {
Self::Local => Ok(Arc::new(LocalDriver::new(policy)?)),
Self::S3 => Ok(Arc::new(S3Driver::new(policy)?)),
+ Self::Sftp => Ok(Arc::new(SftpDriver::new(policy)?)),
Self::AzureBlob => Ok(Arc::new(AzureBlobDriver::new(policy)?)),
Self::TencentCos => Ok(Arc::new(TencentCosDriver::new(policy)?)),
Self::Remote => RemoteConnector::build_cleanup_driver(state, policy, snapshots).await,
@@ -705,6 +738,11 @@ static CONNECTOR_REGISTRATIONS: &[StorageConnectorRegistration] = &[
connector: BuiltinStorageConnector::S3,
cleanup_snapshot_required: false,
},
+ StorageConnectorRegistration {
+ driver_type: DriverType::Sftp,
+ connector: BuiltinStorageConnector::Sftp,
+ cleanup_snapshot_required: false,
+ },
StorageConnectorRegistration {
driver_type: DriverType::AzureBlob,
connector: BuiltinStorageConnector::AzureBlob,
diff --git a/src/storage/connectors/sftp.rs b/src/storage/connectors/sftp.rs
new file mode 100644
index 000000000..bbddddfd1
--- /dev/null
+++ b/src/storage/connectors/sftp.rs
@@ -0,0 +1,159 @@
+use async_trait::async_trait;
+
+use crate::entities::storage_policy;
+use crate::errors::Result;
+use crate::runtime::RemoteProtocolRuntimeState;
+use crate::storage::StorageDriver;
+use crate::storage::connector_descriptor::{
+ StorageConnectorCapabilities, StorageConnectorCredentialMode, StorageConnectorDescriptor,
+ StorageConnectorDescriptorProvider, StorageConnectorFieldDisplayInput,
+ StorageConnectorFieldKind, StorageConnectorFieldScope, StorageConnectorUiDescriptorInput,
+ StorageConnectorUploadWorkflows, draft_connection_test_action_descriptor,
+ saved_connection_test_action_descriptor, server_relay_simple_upload_capabilities,
+ storage_connector_field, storage_connector_field_with_display, storage_connector_ui_descriptor,
+};
+use crate::storage::drivers::sftp::SftpDriver;
+use crate::types::DriverType;
+
+use super::common::{ensure_onedrive_options_absent, validate_static_secret_credentials};
+use super::{StorageConnector, StorageConnectorConnectionInput, StorageConnectorUploadTransport};
+
+pub struct SftpConnector;
+
+impl StorageConnectorDescriptorProvider for SftpConnector {
+ fn storage_connector_descriptor() -> StorageConnectorDescriptor {
+ StorageConnectorDescriptor {
+ driver_type: DriverType::Sftp,
+ enabled: true,
+ label: "SFTP".to_string(),
+ description: "SSH File Transfer Protocol storage policy".to_string(),
+ ui: storage_connector_ui_descriptor(StorageConnectorUiDescriptorInput {
+ label_key: "driver_type_sftp",
+ description_key: "policy_wizard_sftp_storage_desc",
+ icon_src: None,
+ icon_name: Some("ServerCog"),
+ helper_key: "policy_wizard_sftp_helper",
+ config_step_title_key: "policy_wizard_step_sftp_title",
+ config_step_description_key: "policy_wizard_step_sftp_desc",
+ edit_context_key: "policy_edit_context_sftp_desc",
+ base_path_empty_display: "core:root",
+ base_path_placeholder: "/srv/asterdrive",
+ }),
+ credential_mode: StorageConnectorCredentialMode::StaticSecret,
+ requires_authorization: false,
+ authorization_provider: None,
+ capabilities: StorageConnectorCapabilities {
+ efficient_range: true,
+ capacity: false,
+ list: false,
+ presigned_download: false,
+ storage_native_thumbnail: false,
+ storage_native_media_metadata: false,
+ remote_node_binding: false,
+ object_storage_transfer_strategy: false,
+ },
+ upload_workflows: StorageConnectorUploadWorkflows {
+ simple_upload: true,
+ simple_upload_capabilities: server_relay_simple_upload_capabilities(None),
+ stream_upload: true,
+ object_multipart_upload: false,
+ object_multipart_upload_capabilities: None,
+ provider_resumable_upload: false,
+ presigned_upload: false,
+ frontend_direct_provider_resumable_upload: false,
+ provider_resumable_upload_capabilities: None,
+ },
+ fields: vec![
+ storage_connector_field_with_display(StorageConnectorFieldDisplayInput {
+ name: "endpoint",
+ scope: StorageConnectorFieldScope::Connection,
+ kind: StorageConnectorFieldKind::Text,
+ required: true,
+ secret: false,
+ label_key: "endpoint",
+ placeholder: Some("sftp://example.com:22"),
+ help_key: Some("sftp_endpoint_hint"),
+ required_message_key: None,
+ invalid_protocol_message_key: Some("sftp_endpoint_protocol_required_error"),
+ trim_on_blur: true,
+ visible_when_driver_types: Vec::new(),
+ }),
+ storage_connector_field(
+ "access_key",
+ StorageConnectorFieldScope::Connection,
+ StorageConnectorFieldKind::Text,
+ true,
+ false,
+ ),
+ storage_connector_field(
+ "secret_key",
+ StorageConnectorFieldScope::Connection,
+ StorageConnectorFieldKind::Secret,
+ true,
+ true,
+ ),
+ storage_connector_field(
+ "base_path",
+ StorageConnectorFieldScope::Connection,
+ StorageConnectorFieldKind::Text,
+ false,
+ false,
+ ),
+ ],
+ actions: vec![
+ draft_connection_test_action_descriptor(),
+ saved_connection_test_action_descriptor(false),
+ ],
+ driver_recommendations: Vec::new(),
+ related_issues: vec![125],
+ }
+ }
+}
+
+#[async_trait(?Send)]
+impl StorageConnector for SftpConnector {
+ fn driver_type() -> DriverType {
+ DriverType::Sftp
+ }
+
+ fn normalize_connection_fields(endpoint: &str, bucket: &str) -> Result<(String, String)> {
+ let _ = bucket;
+ Ok((SftpDriver::normalize_endpoint(endpoint)?, String::new()))
+ }
+
+ fn validate_connection_credentials(input: &StorageConnectorConnectionInput) -> Result<()> {
+ validate_static_secret_credentials(input, "SFTP")?;
+ SftpDriver::validate_connection_parts(
+ &input.endpoint,
+ &input.access_key,
+ &input.secret_key,
+ &input.base_path,
+ )
+ }
+
+ fn supports_saved_draft_credentials() -> bool {
+ true
+ }
+
+ async fn validate_policy_options(
+ db: &C,
+ remote_node_id: Option,
+ options: &crate::types::StoragePolicyOptions,
+ ) -> Result<()> {
+ let _ = (db, remote_node_id);
+ ensure_onedrive_options_absent(options)
+ }
+
+ async fn build_draft_driver(
+ state: &S,
+ policy: &storage_policy::Model,
+ ) -> Result> {
+ let _ = state;
+ Ok(Box::new(SftpDriver::new(policy)?))
+ }
+
+ fn upload_transport(policy: &storage_policy::Model) -> StorageConnectorUploadTransport {
+ let _ = policy;
+ StorageConnectorUploadTransport::Sftp
+ }
+}
diff --git a/src/storage/connectors/tests.rs b/src/storage/connectors/tests.rs
index f976cf0aa..cd354ce67 100644
--- a/src/storage/connectors/tests.rs
+++ b/src/storage/connectors/tests.rs
@@ -168,14 +168,27 @@ async fn azure_blob_draft_connection_rejects_saved_credential_driver_mismatch()
assert_saved_credentials_driver_mismatch_for_driver(DriverType::AzureBlob).await;
}
+#[tokio::test]
+async fn sftp_draft_connection_can_merge_saved_credentials() {
+ assert!(SftpConnector::supports_saved_draft_credentials());
+ assert_saved_credentials_merge_for_driver(DriverType::Sftp).await;
+}
+
+#[tokio::test]
+async fn sftp_draft_connection_rejects_saved_credential_driver_mismatch() {
+ assert!(SftpConnector::supports_saved_draft_credentials());
+ assert_saved_credentials_driver_mismatch_for_driver(DriverType::Sftp).await;
+}
+
#[test]
fn descriptors_cover_every_storage_driver() {
let descriptors = list_storage_driver_descriptors();
- assert_eq!(descriptors.len(), 6);
+ assert_eq!(descriptors.len(), 7);
for driver_type in [
DriverType::Local,
DriverType::S3,
+ DriverType::Sftp,
DriverType::AzureBlob,
DriverType::TencentCos,
DriverType::Remote,
@@ -255,6 +268,15 @@ fn descriptors_expose_connector_owned_ui_metadata() {
onedrive.ui.config_step_title_key,
"policy_wizard_step_onedrive_title"
);
+
+ let sftp = descriptor(DriverType::Sftp);
+ assert_eq!(sftp.ui.label_key, "driver_type_sftp");
+ assert_eq!(sftp.ui.helper_key, "policy_wizard_sftp_helper");
+ assert_eq!(
+ sftp.ui.config_step_description_key,
+ "policy_wizard_step_sftp_desc"
+ );
+ assert_eq!(sftp.ui.base_path_empty_display, "core:root");
}
#[test]
@@ -262,6 +284,7 @@ fn connector_registry_covers_every_builtin_storage_driver() {
for driver_type in [
DriverType::Local,
DriverType::S3,
+ DriverType::Sftp,
DriverType::AzureBlob,
DriverType::TencentCos,
DriverType::Remote,
@@ -307,6 +330,14 @@ fn transfer_strategy_policy_options_are_declared_by_descriptors() {
let remote = descriptor(DriverType::Remote);
assert!(has_policy_option(&remote, "remote_download_strategy"));
assert!(has_policy_option(&remote, "remote_upload_strategy"));
+
+ let sftp = descriptor(DriverType::Sftp);
+ assert!(!has_policy_option(&sftp, "object_storage_upload_strategy"));
+ assert!(!has_policy_option(
+ &sftp,
+ "object_storage_download_strategy"
+ ));
+ assert!(!has_policy_option(&sftp, "s3_path_style"));
}
#[test]
@@ -422,6 +453,34 @@ fn object_storage_connection_field_display_metadata_is_connector_owned() {
assert!(!has_policy_option(&tencent_cos, "s3_path_style"));
}
+#[test]
+fn sftp_connection_field_display_metadata_is_connector_owned() {
+ let sftp = descriptor(DriverType::Sftp);
+
+ assert_eq!(sftp.ui.label_key, "driver_type_sftp");
+ assert_eq!(sftp.ui.helper_key, "policy_wizard_sftp_helper");
+ assert_eq!(sftp.ui.edit_context_key, "policy_edit_context_sftp_desc");
+ assert_eq!(
+ field(&sftp, "endpoint").placeholder.as_deref(),
+ Some("sftp://example.com:22")
+ );
+ assert_eq!(
+ field(&sftp, "endpoint").help_key.as_deref(),
+ Some("sftp_endpoint_hint")
+ );
+ assert_eq!(
+ field(&sftp, "endpoint")
+ .invalid_protocol_message_key
+ .as_deref(),
+ Some("sftp_endpoint_protocol_required_error")
+ );
+ assert_eq!(field(&sftp, "access_key").label_key, "access_key");
+ assert_eq!(field(&sftp, "secret_key").label_key, "secret_key");
+ assert!(sftp.fields.iter().all(|field| field.name != "bucket"));
+ assert!(!sftp.upload_workflows.presigned_upload);
+ assert!(!sftp.upload_workflows.object_multipart_upload);
+}
+
#[test]
fn object_storage_multipart_etag_requirements_are_connector_owned() {
for (driver_type, expected_etag_required) in [
@@ -624,6 +683,7 @@ fn runtime_credential_requirement_is_connector_owned() {
for driver_type in [
DriverType::Local,
DriverType::S3,
+ DriverType::Sftp,
DriverType::AzureBlob,
DriverType::TencentCos,
DriverType::Remote,
@@ -1007,6 +1067,7 @@ fn non_local_upload_transports_expose_opaque_blob_hash_prefix() {
StorageConnectorUploadTransport::Remote(RemoteUploadStrategy::RelayStream),
StorageConnectorUploadTransport::Remote(RemoteUploadStrategy::Presigned),
StorageConnectorUploadTransport::StreamUpload,
+ StorageConnectorUploadTransport::Sftp,
] {
assert!(
transport.opaque_blob_hash_prefix().is_some(),
@@ -1032,6 +1093,7 @@ fn presigned_download_policy_is_connector_owned() {
1024,
r#"{"object_storage_download_strategy":"relay_stream"}"#,
);
+ let sftp = mock_policy(DriverType::Sftp, 1024, "{}");
assert!(presigned_download_enabled(&s3).expect("presigned download support should resolve"));
assert!(
@@ -1040,6 +1102,7 @@ fn presigned_download_policy_is_connector_owned() {
assert!(
!presigned_download_enabled(&relay_s3).expect("presigned download support should resolve")
);
+ assert!(!presigned_download_enabled(&sftp).expect("presigned download support should resolve"));
}
#[test]
@@ -1282,6 +1345,45 @@ fn onedrive_uses_server_relay_without_presigned_or_multipart_tracking() {
);
}
+#[test]
+fn sftp_uses_server_relay_without_presigned_or_multipart_tracking() {
+ let policy = mock_policy(DriverType::Sftp, 1024, "{}");
+ let transport =
+ resolve_policy_upload_transport(&policy).expect("upload transport should resolve");
+
+ assert_eq!(transport, StorageConnectorUploadTransport::Sftp);
+ assert_eq!(
+ transport.resolve_init_mode(&policy, 1024),
+ UploadMode::Direct
+ );
+ assert_eq!(
+ transport.resolve_init_mode(&policy, 1025),
+ UploadMode::Chunked
+ );
+ assert!(!transport.supports_streaming_direct_upload(&policy, 0));
+ assert!(transport.supports_streaming_direct_upload(&policy, 1024));
+ assert!(!transport.supports_streaming_direct_upload(&policy, 1025));
+ assert!(!transport.uses_relay_multipart_tracking());
+ assert_eq!(transport.opaque_blob_hash_prefix(), Some("sftp"));
+ assert_eq!(
+ transport.chunked_completion(),
+ StorageConnectorChunkedCompletion::RelayLocalChunksToStreamUpload
+ );
+}
+
+#[test]
+fn sftp_zero_chunk_size_uses_single_streaming_request() {
+ let policy = mock_policy(DriverType::Sftp, 0, "{}");
+ let transport =
+ resolve_policy_upload_transport(&policy).expect("upload transport should resolve");
+
+ assert_eq!(
+ transport.resolve_init_mode(&policy, i64::MAX),
+ UploadMode::Direct
+ );
+ assert!(transport.supports_streaming_direct_upload(&policy, i64::MAX));
+}
+
#[test]
fn upload_workflow_descriptors_match_default_connector_transports() {
assert_upload_workflow_alignment(
@@ -1334,6 +1436,20 @@ fn upload_workflow_descriptors_match_default_connector_transports() {
chunked_completion: StorageConnectorChunkedCompletion::RelayLocalChunksToStreamUpload,
},
);
+ assert_upload_workflow_alignment(
+ DriverType::Sftp,
+ "{}",
+ ExpectedUploadWorkflow {
+ transport: StorageConnectorUploadTransport::Sftp,
+ object_multipart: false,
+ provider_resumable: false,
+ presigned: false,
+ frontend_direct_provider_resumable: false,
+ small_mode: UploadMode::Direct,
+ large_mode: UploadMode::Chunked,
+ chunked_completion: StorageConnectorChunkedCompletion::RelayLocalChunksToStreamUpload,
+ },
+ );
assert_upload_workflow_alignment(
DriverType::OneDrive,
"{}",
@@ -1411,7 +1527,8 @@ fn assert_upload_workflow_alignment(
StorageConnectorUploadTransport::ObjectStorage(_) => OBJECT_STORAGE_LARGE_UPLOAD_SIZE,
StorageConnectorUploadTransport::Local
| StorageConnectorUploadTransport::Remote(_)
- | StorageConnectorUploadTransport::StreamUpload => 2048,
+ | StorageConnectorUploadTransport::StreamUpload
+ | StorageConnectorUploadTransport::Sftp => 2048,
};
let descriptor = descriptor(driver_type);
let workflows = descriptor.upload_workflows;
diff --git a/src/storage/connectors/upload.rs b/src/storage/connectors/upload.rs
index 63c909b07..768a26576 100644
--- a/src/storage/connectors/upload.rs
+++ b/src/storage/connectors/upload.rs
@@ -21,6 +21,9 @@ pub enum StorageConnectorUploadTransport {
/// driver may create Microsoft Graph upload sessions internally, but the
/// upload service only sees a generic stream-upload target.
StreamUpload,
+ /// SFTP can only be reached by the server. Browsers never receive a
+ /// provider-native upload URL; uploads are relayed through StreamUploadDriver.
+ Sftp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -38,7 +41,7 @@ impl StorageConnectorUploadTransport {
pub fn effective_chunk_size(self, policy: &storage_policy::Model) -> i64 {
match self {
Self::ObjectStorage(_) => effective_object_multipart_chunk_size(policy.chunk_size),
- Self::Local | Self::Remote(_) | Self::StreamUpload => policy.chunk_size,
+ Self::Local | Self::Remote(_) | Self::StreamUpload | Self::Sftp => policy.chunk_size,
}
}
@@ -82,6 +85,7 @@ impl StorageConnectorUploadTransport {
Self::Remote(RemoteUploadStrategy::RelayStream)
| Self::Remote(RemoteUploadStrategy::Presigned) => true,
Self::StreamUpload => true,
+ Self::Sftp => self.fits_single_request(policy, declared_size),
}
}
@@ -109,6 +113,7 @@ impl StorageConnectorUploadTransport {
Self::ObjectStorage(_) => Some("s3"),
Self::Remote(_) => Some("remote"),
Self::StreamUpload => Some("provider"),
+ Self::Sftp => Some("sftp"),
}
}
@@ -122,7 +127,7 @@ impl StorageConnectorUploadTransport {
// full temp file. They can relay stored chunks into the connector's
// stream-upload implementation, which lets the concrete driver own
// any provider-native resumable/session behavior internally.
- Self::Remote(_) | Self::StreamUpload => {
+ Self::Remote(_) | Self::StreamUpload | Self::Sftp => {
StorageConnectorChunkedCompletion::RelayLocalChunksToStreamUpload
}
Self::Local | Self::ObjectStorage(_) => {
diff --git a/src/storage/drivers/mod.rs b/src/storage/drivers/mod.rs
index e090fb66d..00b29b15d 100644
--- a/src/storage/drivers/mod.rs
+++ b/src/storage/drivers/mod.rs
@@ -9,4 +9,5 @@ pub mod remote;
pub mod s3;
pub mod s3_compatible;
pub mod s3_config;
+pub mod sftp;
pub mod tencent_cos;
diff --git a/src/storage/drivers/sftp.rs b/src/storage/drivers/sftp.rs
new file mode 100644
index 000000000..200b677b0
--- /dev/null
+++ b/src/storage/drivers/sftp.rs
@@ -0,0 +1,839 @@
+//! Storage driver implementation for `sftp`.
+
+use async_trait::async_trait;
+use russh::client::{self, Handler};
+use russh_sftp::client::{Config as SftpClientConfig, SftpSession, error::Error as SftpError};
+use russh_sftp::protocol::StatusCode;
+use std::io::SeekFrom;
+use std::pin::Pin;
+use std::sync::Arc;
+use std::task::{Context, Poll};
+use std::time::Duration;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, ReadBuf};
+
+use crate::entities::storage_policy;
+use crate::errors::{AsterError, Result};
+use crate::storage::error::{StorageErrorKind, storage_driver_error};
+use crate::storage::{BlobMetadata, StorageDriver, StreamUploadDriver};
+
+const DEFAULT_SFTP_PORT: u16 = 22;
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
+const IO_TIMEOUT: Duration = Duration::from_secs(30);
+
+#[derive(Debug, Clone)]
+struct SftpEndpoint {
+ host: String,
+ port: u16,
+}
+
+#[derive(Debug, Clone)]
+pub struct SftpDriver {
+ endpoint: SftpEndpoint,
+ username: String,
+ password: String,
+ base_path: String,
+}
+
+struct TrustServerKeyClient;
+
+impl Handler for TrustServerKeyClient {
+ type Error = russh::Error;
+
+ async fn check_server_key(
+ &mut self,
+ _server_public_key: &russh::keys::PublicKey,
+ ) -> std::result::Result {
+ Ok(true)
+ }
+}
+
+struct SftpConnection {
+ _ssh: client::Handle,
+ sftp: SftpSession,
+}
+
+struct SftpFileReader {
+ _connection: SftpConnection,
+ file: russh_sftp::client::fs::File,
+}
+
+impl AsyncRead for SftpFileReader {
+ fn poll_read(
+ mut self: Pin<&mut Self>,
+ cx: &mut Context<'_>,
+ buf: &mut ReadBuf<'_>,
+ ) -> Poll> {
+ Pin::new(&mut self.file).poll_read(cx, buf)
+ }
+}
+
+impl SftpDriver {
+ pub fn validate_policy(policy: &storage_policy::Model) -> Result<()> {
+ Self::validate_connection_parts(
+ &policy.endpoint,
+ &policy.access_key,
+ &policy.secret_key,
+ &policy.base_path,
+ )
+ }
+
+ pub(crate) fn validate_connection_parts(
+ endpoint: &str,
+ username: &str,
+ password: &str,
+ base_path: &str,
+ ) -> Result<()> {
+ parse_sftp_endpoint(endpoint)?;
+ validate_connection_secret(username, "access_key")?;
+ validate_connection_secret(password, "secret_key")?;
+ normalize_remote_base_path(base_path)?;
+ Ok(())
+ }
+
+ pub(crate) fn normalize_endpoint(endpoint: &str) -> Result {
+ let endpoint = endpoint.trim();
+ parse_sftp_endpoint(endpoint)?;
+ Ok(endpoint.to_string())
+ }
+
+ pub fn new(policy: &storage_policy::Model) -> Result {
+ Self::validate_policy(policy)?;
+ Ok(Self {
+ endpoint: parse_sftp_endpoint(&policy.endpoint)?,
+ username: policy.access_key.clone(),
+ password: policy.secret_key.clone(),
+ base_path: normalize_remote_base_path(&policy.base_path)?,
+ })
+ }
+
+ async fn connect(&self) -> Result {
+ let mut config = russh::client::Config::default();
+ config.inactivity_timeout = Some(IO_TIMEOUT);
+ config.keepalive_interval = Some(Duration::from_secs(10));
+ config.nodelay = true;
+
+ let address = (self.endpoint.host.clone(), self.endpoint.port);
+ let mut ssh = timeout_io(
+ "connect SFTP endpoint",
+ CONNECT_TIMEOUT,
+ russh::client::connect(Arc::new(config), address, TrustServerKeyClient),
+ )
+ .await?
+ .map_err(|error| map_ssh_error("connect SFTP endpoint failed", error))?;
+
+ let auth = timeout_io(
+ "SFTP authentication",
+ IO_TIMEOUT,
+ ssh.authenticate_password(self.username.clone(), self.password.clone()),
+ )
+ .await?
+ .map_err(|error| map_ssh_error("SFTP authentication failed", error))?;
+ if !auth.success() {
+ return Err(storage_driver_error(
+ StorageErrorKind::Auth,
+ "SFTP authentication failed",
+ ));
+ }
+
+ let channel = timeout_io(
+ "open SSH session channel",
+ IO_TIMEOUT,
+ ssh.channel_open_session(),
+ )
+ .await?
+ .map_err(|error| map_ssh_error("open SSH session channel failed", error))?;
+ timeout_io(
+ "open SFTP subsystem",
+ IO_TIMEOUT,
+ channel.request_subsystem(true, "sftp"),
+ )
+ .await?
+ .map_err(|error| map_ssh_error("open SFTP subsystem failed", error))?;
+
+ let sftp_config = SftpClientConfig {
+ request_timeout_secs: IO_TIMEOUT.as_secs(),
+ ..Default::default()
+ };
+ let sftp = timeout_io(
+ "initialize SFTP session",
+ IO_TIMEOUT,
+ SftpSession::new_with_config(channel.into_stream(), sftp_config),
+ )
+ .await?
+ .map_err(|error| map_sftp_error("initialize SFTP session failed", error))?;
+ sftp.set_timeout(IO_TIMEOUT.as_secs());
+
+ Ok(SftpConnection { _ssh: ssh, sftp })
+ }
+
+ fn full_path(&self, path: &str) -> Result {
+ let relative = sanitize_relative_storage_path(path)?;
+ join_remote_path(&self.base_path, &relative)
+ }
+
+ async fn open_reader(&self, path: &str, offset: u64) -> Result {
+ let remote_path = self.full_path(path)?;
+ let connection = self.connect().await?;
+ let mut file = connection
+ .sftp
+ .open(remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP open failed", error))?;
+ if offset > 0 {
+ file.seek(SeekFrom::Start(offset))
+ .await
+ .map_err(|error| map_io_error("SFTP seek failed", error))?;
+ }
+ Ok(SftpFileReader {
+ _connection: connection,
+ file,
+ })
+ }
+}
+
+#[async_trait]
+impl StorageDriver for SftpDriver {
+ async fn put(&self, path: &str, data: &[u8]) -> Result {
+ let remote_path = self.full_path(path)?;
+ let connection = self.connect().await?;
+ ensure_remote_parent_dir(&connection.sftp, &remote_path).await?;
+ let mut file = connection
+ .sftp
+ .create(remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP create failed", error))?;
+ file.write_all(data)
+ .await
+ .map_err(|error| map_io_error("SFTP write failed", error))?;
+ file.flush()
+ .await
+ .map_err(|error| map_io_error("SFTP flush failed", error))?;
+ file.shutdown()
+ .await
+ .map_err(|error| map_io_error("SFTP close failed", error))?;
+ Ok(path.to_string())
+ }
+
+ async fn get(&self, path: &str) -> Result> {
+ let remote_path = self.full_path(path)?;
+ let connection = self.connect().await?;
+ connection
+ .sftp
+ .read(remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP read failed", error))
+ }
+
+ async fn get_stream(&self, path: &str) -> Result> {
+ Ok(Box::new(self.open_reader(path, 0).await?))
+ }
+
+ async fn get_range(
+ &self,
+ path: &str,
+ offset: u64,
+ length: Option,
+ ) -> Result> {
+ if length == Some(0) {
+ return Ok(Box::new(tokio::io::empty()));
+ }
+
+ let reader = self.open_reader(path, offset).await?;
+ Ok(match length {
+ Some(len) => Box::new(reader.take(len)),
+ None => Box::new(reader),
+ })
+ }
+
+ fn supports_efficient_range(&self) -> bool {
+ true
+ }
+
+ async fn delete(&self, path: &str) -> Result<()> {
+ let remote_path = self.full_path(path)?;
+ let connection = self.connect().await?;
+ connection
+ .sftp
+ .remove_file(remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP delete failed", error))
+ }
+
+ async fn exists(&self, path: &str) -> Result {
+ let remote_path = self.full_path(path)?;
+ let connection = self.connect().await?;
+ match connection.sftp.metadata(remote_path).await {
+ Ok(_) => Ok(true),
+ Err(error) if is_sftp_not_found(&error) => Ok(false),
+ Err(error) => Err(map_sftp_error("SFTP stat failed", error)),
+ }
+ }
+
+ async fn metadata(&self, path: &str) -> Result {
+ let remote_path = self.full_path(path)?;
+ let connection = self.connect().await?;
+ let stat = connection
+ .sftp
+ .metadata(remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP stat failed", error))?;
+ Ok(BlobMetadata {
+ size: stat.size.unwrap_or(0),
+ content_type: None,
+ })
+ }
+
+ async fn copy_object(&self, src_path: &str, dest_path: &str) -> Result {
+ let src_remote_path = self.full_path(src_path)?;
+ let dest_remote_path = self.full_path(dest_path)?;
+ let connection = self.connect().await?;
+ ensure_remote_parent_dir(&connection.sftp, &dest_remote_path).await?;
+ let mut src = connection
+ .sftp
+ .open(src_remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP source open failed", error))?;
+ let mut dest = connection
+ .sftp
+ .create(dest_remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP destination create failed", error))?;
+ tokio::io::copy(&mut src, &mut dest)
+ .await
+ .map_err(|error| map_io_error("SFTP copy failed", error))?;
+ dest.flush()
+ .await
+ .map_err(|error| map_io_error("SFTP copy flush failed", error))?;
+ dest.shutdown()
+ .await
+ .map_err(|error| map_io_error("SFTP copy close failed", error))?;
+ Ok(dest_path.to_string())
+ }
+
+ fn as_stream_upload(&self) -> Option<&dyn StreamUploadDriver> {
+ Some(self)
+ }
+}
+
+#[async_trait]
+impl StreamUploadDriver for SftpDriver {
+ async fn put_reader(
+ &self,
+ storage_path: &str,
+ mut reader: Box,
+ _size: i64,
+ ) -> Result {
+ let remote_path = self.full_path(storage_path)?;
+ let connection = self.connect().await?;
+ ensure_remote_parent_dir(&connection.sftp, &remote_path).await?;
+ let mut remote_file = connection
+ .sftp
+ .create(remote_path)
+ .await
+ .map_err(|error| map_sftp_error("SFTP create failed", error))?;
+ tokio::io::copy(&mut reader, &mut remote_file)
+ .await
+ .map_err(|error| map_io_error("SFTP stream upload failed", error))?;
+ remote_file
+ .flush()
+ .await
+ .map_err(|error| map_io_error("SFTP stream flush failed", error))?;
+ remote_file
+ .shutdown()
+ .await
+ .map_err(|error| map_io_error("SFTP stream close failed", error))?;
+ Ok(storage_path.to_string())
+ }
+
+ async fn put_file(&self, storage_path: &str, local_path: &str) -> Result {
+ let local_file = tokio::fs::File::open(local_path)
+ .await
+ .map_err(|error| map_io_error("open local upload file failed", error))?;
+ self.put_reader(storage_path, Box::new(local_file), -1)
+ .await
+ }
+}
+
+async fn timeout_io(context: &'static str, duration: Duration, future: F) -> Result
+where
+ F: std::future::Future