99// Copyright (c) 2024-2026 axpnet: AI-assisted (see AI-TRANSPARENCY.md)
1010
1111use super :: types:: is_session_closed_error_message;
12- use super :: { ProviderError , ProviderType , RemoteEntry , SftpConfig , StorageProvider } ;
12+ use super :: {
13+ ProviderError , ProviderTransferExecutorKind , ProviderType , RemoteEntry , SftpConfig ,
14+ StorageProvider ,
15+ } ;
1316use crate :: ssh_exec:: ssh_exec_collect;
1417use async_trait:: async_trait;
1518use russh:: client:: AuthResult ;
@@ -194,6 +197,51 @@ impl Handler for SshHandler {
194197 }
195198}
196199
200+ /// Secure connection spec retained after `connect()` so the shared
201+ /// transfer engine can re-dial N **independent** SSH+SFTP connections for
202+ /// file-level parallelism (PD-SFTP-1).
203+ ///
204+ /// This mirrors `FtpConnectionSpec` / `FtpManager::connection_spec()`:
205+ /// `provider_connect` zeroizes the outer config password after the first
206+ /// connect, so the provider must retain its own `SecretString` copy.
207+ /// Holding credentials for the provider's lifetime is the exact security
208+ /// posture FTP already ships. Secrets are only exposed (`ExposeSecret`)
209+ /// at dial time, never on IPC or in logs.
210+ #[ derive( Clone ) ]
211+ pub struct SftpConnectionSpec {
212+ pub host : String ,
213+ pub port : u16 ,
214+ pub username : String ,
215+ pub password : Option < secrecy:: SecretString > ,
216+ pub private_key_path : Option < String > ,
217+ pub key_passphrase : Option < secrecy:: SecretString > ,
218+ pub initial_path : Option < String > ,
219+ pub timeout_secs : u64 ,
220+ /// SHA-256 hex of the host key accepted by the first connect. Pool
221+ /// re-dials verify the new connection's key against this (defense in
222+ /// depth on top of `known_hosts`), same posture as the U-02 rsync pin.
223+ pub pinned_host_key_sha256 : Option < String > ,
224+ }
225+
226+ impl SftpConnectionSpec {
227+ /// Rebuild an `SftpConfig` for an independent worker. `trust_unknown_hosts`
228+ /// is forced to `false`: a pooled re-dial must never TOFU, the host key
229+ /// is already in `known_hosts` from the first connect.
230+ fn to_config ( & self ) -> SftpConfig {
231+ SftpConfig {
232+ host : self . host . clone ( ) ,
233+ port : self . port ,
234+ username : self . username . clone ( ) ,
235+ password : self . password . clone ( ) ,
236+ private_key_path : self . private_key_path . clone ( ) ,
237+ key_passphrase : self . key_passphrase . clone ( ) ,
238+ initial_path : self . initial_path . clone ( ) ,
239+ timeout_secs : self . timeout_secs ,
240+ trust_unknown_hosts : false ,
241+ }
242+ }
243+ }
244+
197245/// SFTP Provider
198246///
199247/// Provides secure file transfer over SSH using the SFTP protocol.
@@ -221,6 +269,12 @@ pub struct SftpProvider {
221269 /// SSH connection (U-02) so the fresh TCP socket it opens for
222270 /// `aerorsync_serve` does not skip host-key verification.
223271 host_key_sha256_hex : Arc < std:: sync:: OnceLock < String > > ,
272+ /// Secure connection spec for re-dialling independent pool sessions.
273+ /// `Some` once captured: either at the end of a successful `connect()`
274+ /// (before `provider_connect` zeroizes the outer config) or when this
275+ /// provider was produced by `clone_for_transfer()` as a not-yet-
276+ /// connected pool worker.
277+ connection_spec : Option < SftpConnectionSpec > ,
224278}
225279
226280impl SftpProvider {
@@ -241,6 +295,7 @@ impl SftpProvider {
241295 // in practice. Override per-call with --chunk-size / --buffer-size.
242296 buffer_size : 256 * 1024 ,
243297 host_key_sha256_hex : Arc :: new ( std:: sync:: OnceLock :: new ( ) ) ,
298+ connection_spec : None ,
244299 }
245300 }
246301
@@ -255,6 +310,52 @@ impl SftpProvider {
255310 self . host_key_sha256_hex . get ( ) . cloned ( )
256311 }
257312
313+ /// Secure connection spec retained after a successful `connect()`.
314+ /// Mirrors `FtpManager::connection_spec()`. `None` until connected (or
315+ /// until set by `clone_for_transfer()` on a pool worker).
316+ pub fn connection_spec ( & self ) -> Option < SftpConnectionSpec > {
317+ self . connection_spec . clone ( )
318+ }
319+
320+ /// Ensure this provider has its own independent, authenticated SSH+SFTP
321+ /// connection (PD-SFTP-1). A `clone_for_transfer()` worker starts
322+ /// unconnected and carries only the secure spec; the first transfer
323+ /// dials a **separate** SSH connection (separate TCP socket, separate
324+ /// auth) so N files run truly in parallel, exactly like the FTP pool.
325+ ///
326+ /// Host-key safety on the re-dial: `connect()` still goes through
327+ /// `SshHandler` -> `known_hosts` with `trust_unknown_hosts = false`
328+ /// (never TOFU on a pooled dial; the key is already known from the
329+ /// first connect, `KeyChanged` is rejected). Defense in depth: the
330+ /// freshly accepted fingerprint is compared against the pin captured
331+ /// at the first connect and a mismatch aborts the worker.
332+ async fn ensure_connected ( & mut self ) -> Result < ( ) , ProviderError > {
333+ if self . sftp . is_some ( ) {
334+ return Ok ( ( ) ) ;
335+ }
336+ let spec = self
337+ . connection_spec
338+ . clone ( )
339+ . ok_or ( ProviderError :: NotConnected ) ?;
340+ self . config = spec. to_config ( ) ;
341+ // Fresh per-connection slot so the comparison reflects this dial.
342+ self . host_key_sha256_hex = Arc :: new ( std:: sync:: OnceLock :: new ( ) ) ;
343+ self . connect ( ) . await ?;
344+ if let Some ( pinned) = spec. pinned_host_key_sha256 . as_deref ( ) {
345+ match self . accepted_host_key_sha256_hex ( ) . as_deref ( ) {
346+ Some ( seen) if seen == pinned => { }
347+ other => {
348+ let _ = self . disconnect ( ) . await ;
349+ return Err ( ProviderError :: ConnectionFailed ( format ! (
350+ "SFTP pool re-dial host key mismatch (expected {}, got {:?}): aborting worker" ,
351+ pinned, other
352+ ) ) ) ;
353+ }
354+ }
355+ }
356+ Ok ( ( ) )
357+ }
358+
258359 /// Return a cloneable handle to the underlying SSH session, if connected.
259360 ///
260361 /// Exposed to let sibling modules (rsync-over-SSH, port forwarding, ...)
@@ -807,6 +908,27 @@ impl StorageProvider for SftpProvider {
807908 self . ssh_handle = Some ( Arc :: new ( TokioMutex :: new ( handle) ) ) ;
808909 self . sftp = Some ( sftp) ;
809910
911+ // PD-SFTP-1: capture a secure connection spec now, while
912+ // `self.config` still holds the secrets (`provider_connect`
913+ // zeroizes the outer config only after this returns). The pinned
914+ // host-key fingerprint was populated by `SshHandler` during the
915+ // handshake; preserve an earlier pin if this dial reused one.
916+ let prior_pin = self
917+ . connection_spec
918+ . as_ref ( )
919+ . and_then ( |s| s. pinned_host_key_sha256 . clone ( ) ) ;
920+ self . connection_spec = Some ( SftpConnectionSpec {
921+ host : self . config . host . clone ( ) ,
922+ port : self . config . port ,
923+ username : self . config . username . clone ( ) ,
924+ password : self . config . password . clone ( ) ,
925+ private_key_path : self . config . private_key_path . clone ( ) ,
926+ key_passphrase : self . config . key_passphrase . clone ( ) ,
927+ initial_path : self . config . initial_path . clone ( ) ,
928+ timeout_secs : self . config . timeout_secs ,
929+ pinned_host_key_sha256 : self . host_key_sha256_hex . get ( ) . cloned ( ) . or ( prior_pin) ,
930+ } ) ;
931+
810932 tracing:: info!(
811933 "SFTP: Connected successfully to {} (home: {})" ,
812934 self . config. host,
@@ -984,6 +1106,7 @@ impl StorageProvider for SftpProvider {
9841106 local_path : & str ,
9851107 on_progress : Option < Box < dyn Fn ( u64 , u64 ) + Send > > ,
9861108 ) -> Result < ( ) , ProviderError > {
1109+ self . ensure_connected ( ) . await ?;
9871110 let sftp = self . get_sftp ( ) ?;
9881111 let full_path = self . normalize_path ( remote_path) ;
9891112
@@ -1100,6 +1223,7 @@ impl StorageProvider for SftpProvider {
11001223 ) -> Result < ( ) , ProviderError > {
11011224 use tokio:: io:: AsyncWriteExt ;
11021225
1226+ self . ensure_connected ( ) . await ?;
11031227 let sftp = self . get_sftp ( ) ?;
11041228 let full_path = self . normalize_path ( remote_path) ;
11051229
@@ -1581,6 +1705,47 @@ impl StorageProvider for SftpProvider {
15811705 }
15821706 }
15831707
1708+ /// PD-SFTP-1: advertise real file-level parallelism only once a secure
1709+ /// connection spec exists to re-dial independent SSH connections from.
1710+ /// Without it (never connected, or credentials unavailable) SFTP stays
1711+ /// a single locked lease: honest non-regression, no overclaim.
1712+ fn transfer_executor_kind ( & self ) -> ProviderTransferExecutorKind {
1713+ if self . connection_spec . is_some ( ) {
1714+ ProviderTransferExecutorKind :: SftpConnectionPool
1715+ } else {
1716+ ProviderTransferExecutorKind :: LockedSingle
1717+ }
1718+ }
1719+
1720+ /// Conservative initial cap, mirroring the FTP pool clamp (1..8).
1721+ /// Each lease is a full independent SSH connection; raise only after a
1722+ /// live benchmark on the target server says it pays.
1723+ fn transfer_executor_max_sessions ( & self ) -> u16 {
1724+ 4
1725+ }
1726+
1727+ /// Produce an independent transfer worker. It is **not connected**:
1728+ /// it carries only the secure `SftpConnectionSpec` and dials its own
1729+ /// separate SSH connection lazily on the first transfer
1730+ /// (`ensure_connected`). No SSH handle or channel is shared, so N
1731+ /// workers are N independent connections, exactly like the FTP pool.
1732+ fn clone_for_transfer ( & self ) -> Result < Box < dyn StorageProvider > , ProviderError > {
1733+ let spec = self . connection_spec . clone ( ) . ok_or_else ( || {
1734+ ProviderError :: NotSupported (
1735+ "SFTP clone_for_transfer requires a captured connection spec" . to_string ( ) ,
1736+ )
1737+ } ) ?;
1738+ let mut worker = SftpProvider :: new ( spec. to_config ( ) ) ;
1739+ worker. current_dir = self . current_dir . clone ( ) ;
1740+ worker. home_dir = self . home_dir . clone ( ) ;
1741+ worker. download_limit_bps = self . download_limit_bps ;
1742+ worker. upload_limit_bps = self . upload_limit_bps ;
1743+ worker. compression_enabled = self . compression_enabled ;
1744+ worker. buffer_size = self . buffer_size ;
1745+ worker. connection_spec = Some ( spec) ;
1746+ Ok ( Box :: new ( worker) )
1747+ }
1748+
15841749 fn set_chunk_sizes ( & mut self , upload : Option < u64 > , download : Option < u64 > ) {
15851750 // Cap at 16 MB (larger buffers waste memory without improving throughput)
15861751 let cap = 16 * 1024 * 1024 ;
0 commit comments