Skip to main content

ssh_mcp/transfer/
mod.rs

1//! File and directory transfer tool support.
2//!
3//! Transports:
4//! - `sftp`: OpenSSH `sftp` client (batch mode)
5//! - `scp`: OpenSSH `scp` client
6//! - `exec-raw`: stdin/stdout streaming over the existing SSH session
7//! - `auto`: fallback chain `sftp -> scp -> exec-raw`
8
9mod exec_raw;
10mod local_root;
11mod openssh;
12mod process;
13mod rsync;
14mod skeleton;
15mod staging;
16mod tar;
17mod types;
18mod walk;
19
20pub use types::{
21    CompactTransferResponse, ResolvedPaths, RsyncOptions, StagingLocal, StagingRemote,
22    TransferCounts, TransferKind, TransferOperation, TransferParams, TransferResponse,
23    TransferStaging, TransferTransport,
24};
25
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::time::Duration;
30
31use tokio::time::Instant;
32
33use crate::error::{Result, SshMcpError};
34use crate::ssh::{HostKeyCheckMode, SshConnectionManager};
35
36fn io_to_transport_attempt(err: std::io::Error) -> TransportAttemptError {
37    TransportAttemptError::Other(SshMcpError::Io(err))
38}
39
40struct StepCtx<'a> {
41    conn: &'a SshConnectionManager,
42    remote_home: &'a str,
43    id: u64,
44    kind: TransferKind,
45    resolved: &'a ResolvedPaths,
46    timeout: Duration,
47    response: &'a mut TransferResponse,
48}
49
50struct OpenSshContext<'a> {
51    conn: &'a SshConnectionManager,
52    remote_home: &'a str,
53    key_path: Option<&'a Path>,
54    ssh: &'a TransferSshOptions,
55    id: u64,
56    timeout: Duration,
57}
58
59struct OpenSshOperation<'a> {
60    transport: openssh::OpenSshTransport,
61    kind: TransferKind,
62    response: &'a mut TransferResponse,
63}
64
65/// Core transfer engine.
66///
67/// For now this selects the EXEC-RAW transport, but it is structured so that
68/// SFTP/SCP can be added as additional implementations.
69#[derive(Clone, Debug)]
70pub struct TransferEngine {
71    local_root: Arc<PathBuf>,
72    counter: Arc<AtomicU64>,
73}
74
75#[derive(Clone, Debug)]
76pub struct TransferRunContext {
77    pub timeout: Duration,
78    pub ssh: TransferSshOptions,
79}
80
81#[derive(Clone, Debug)]
82pub struct TransferSshOptions {
83    pub host: String,
84    pub port: u16,
85    pub user: String,
86    pub key_path: Option<PathBuf>,
87    pub host_key_checking: HostKeyCheckMode,
88    pub known_hosts: Option<PathBuf>,
89}
90
91impl TransferEngine {
92    pub fn new(local_root: PathBuf) -> Self {
93        Self {
94            local_root: Arc::new(local_root),
95            counter: Arc::new(AtomicU64::new(1)),
96        }
97    }
98
99    pub fn local_root(&self) -> &Path {
100        self.local_root.as_path()
101    }
102
103    fn next_id(&self) -> u64 {
104        self.counter.fetch_add(1, Ordering::Relaxed)
105    }
106
107    pub async fn run(
108        &self,
109        conn: &SshConnectionManager,
110        params: TransferParams,
111        ctx: TransferRunContext,
112    ) -> TransferResponse {
113        let key_path_opt = ctx.ssh.key_path.clone();
114
115        let started_at = Instant::now();
116        let id = self.next_id();
117
118        let remote_home = match exec_raw::resolve_remote_home(conn, ctx.timeout).await {
119            Ok(home) => home,
120            Err(e) => {
121                return TransferResponse::error(
122                    params,
123                    self.local_root(),
124                    &format!("failed to resolve remote HOME: {e}"),
125                );
126            }
127        };
128
129        let mut response = TransferResponse::ok_stub(
130            params,
131            TransferTransport::ExecRaw,
132            &remote_home,
133            self.local_root(),
134        );
135
136        let kind = match resolve_kind(conn, self.local_root(), &response.params, ctx.timeout).await
137        {
138            Ok(kind) => kind,
139            Err(e) => {
140                response.set_error(&format!("failed to resolve transfer kind: {e}"));
141                response.elapsed_ms = Some(started_at.elapsed().as_millis() as u64);
142                return response;
143            }
144        };
145        response.kind = Some(kind);
146
147        let transports = match response.params.transport {
148            TransferTransport::Auto => {
149                vec![
150                    TransferTransport::Rsync,   // Try rsync first (most efficient)
151                    TransferTransport::Sftp,    // Fallback to sftp
152                    TransferTransport::Scp,     // Fallback to scp
153                    TransferTransport::ExecRaw, // Last resort
154                ]
155            }
156            other => vec![other],
157        };
158
159        let mut attempted_transports: Vec<TransferTransport> = Vec::new();
160        let mut unsupported_reasons: Vec<String> = Vec::new();
161        let mut failed_reasons: Vec<String> = Vec::new();
162
163        for transport in transports {
164            attempted_transports.push(transport);
165            response.transport_used = transport;
166            let attempt = match transport {
167                TransferTransport::ExecRaw => self
168                    .run_exec_raw(conn, &remote_home, id, kind, ctx.timeout, &mut response)
169                    .await
170                    .map_err(TransportAttemptError::Other),
171                TransferTransport::Sftp => {
172                    self.run_openssh(
173                        OpenSshContext {
174                            conn,
175                            remote_home: &remote_home,
176                            key_path: key_path_opt.as_deref(),
177                            ssh: &ctx.ssh,
178                            id,
179                            timeout: ctx.timeout,
180                        },
181                        OpenSshOperation {
182                            transport: openssh::OpenSshTransport::Sftp,
183                            kind,
184                            response: &mut response,
185                        },
186                    )
187                    .await
188                }
189                TransferTransport::Scp => {
190                    self.run_openssh(
191                        OpenSshContext {
192                            conn,
193                            remote_home: &remote_home,
194                            key_path: key_path_opt.as_deref(),
195                            ssh: &ctx.ssh,
196                            id,
197                            timeout: ctx.timeout,
198                        },
199                        OpenSshOperation {
200                            transport: openssh::OpenSshTransport::Scp,
201                            kind,
202                            response: &mut response,
203                        },
204                    )
205                    .await
206                }
207                TransferTransport::Auto => {
208                    Err(TransportAttemptError::Other(SshMcpError::connection(
209                        "internal error: transport=auto should have been expanded",
210                    )))
211                }
212                TransferTransport::Rsync => {
213                    self.run_rsync(
214                        OpenSshContext {
215                            conn,
216                            remote_home: &remote_home,
217                            key_path: key_path_opt.as_deref(),
218                            ssh: &ctx.ssh,
219                            id,
220                            timeout: ctx.timeout,
221                        },
222                        kind,
223                        &mut response,
224                    )
225                    .await
226                }
227            };
228
229            match attempt {
230                Ok(()) => {
231                    response.ok = true;
232                    break;
233                }
234                Err(TransportAttemptError::Unsupported { transport, reason }) => {
235                    unsupported_reasons.push(format!("{transport:?}: {reason}"));
236                    continue;
237                }
238                Err(e) => {
239                    if response.params.transport == TransferTransport::Auto {
240                        failed_reasons.push(format!("{transport:?}: {e}"));
241                        continue;
242                    }
243                    response.set_error(&e.to_string());
244                    break;
245                }
246            }
247        }
248
249        // Only populate fallback_chain when the original transport was Auto
250        if response.params.transport == TransferTransport::Auto {
251            response.fallback_chain = attempted_transports;
252        }
253
254        if !response.ok
255            && response.error.is_none()
256            && response.params.transport == TransferTransport::Auto
257        {
258            // If we have both unsupported and failed errors, include all of them
259            let all_reasons: Vec<String> = unsupported_reasons
260                .into_iter()
261                .chain(failed_reasons)
262                .collect();
263
264            if all_reasons.is_empty() {
265                response.set_error("all transfer transports failed");
266            } else {
267                response.set_error(&format!(
268                    "all auto transports failed: {}",
269                    all_reasons.join("; ")
270                ));
271            }
272        }
273
274        response.elapsed_ms = Some(started_at.elapsed().as_millis() as u64);
275        response
276    }
277
278    async fn run_exec_raw(
279        &self,
280        conn: &SshConnectionManager,
281        remote_home: &str,
282        id: u64,
283        kind: TransferKind,
284        timeout: Duration,
285        response: &mut TransferResponse,
286    ) -> Result<()> {
287        let resolved = self
288            .resolve_and_validate_local_paths(&response.params, kind)
289            .await?;
290        response.resolved_paths = Some(resolved.clone());
291
292        let mut ctx = StepCtx {
293            conn,
294            remote_home,
295            id,
296            kind,
297            resolved: &resolved,
298            timeout,
299            response,
300        };
301
302        match ctx.response.params.operation {
303            TransferOperation::Put => self.put(&mut ctx).await,
304            TransferOperation::Get => self.get(&mut ctx).await,
305        }
306    }
307
308    async fn put(&self, ctx: &mut StepCtx<'_>) -> Result<()> {
309        let raw_ctx = exec_raw::ExecRawCtx {
310            conn: ctx.conn,
311            id: ctx.id,
312            timeout: ctx.timeout,
313        };
314
315        match ctx.kind {
316            TransferKind::File => {
317                let (staging, counts) = exec_raw::put_file_exec_raw(exec_raw::PutFileExecRawArgs {
318                    ctx: raw_ctx,
319                    remote_home: ctx.remote_home,
320                    local_src: &ctx.resolved.local_path,
321                    remote_dst: &ctx.response.params.remote_path,
322                    overwrite: ctx.response.params.overwrite,
323                })
324                .await?;
325                ctx.response.staging = Some(staging);
326                ctx.response.counts = Some(counts);
327                Ok(())
328            }
329            TransferKind::Directory => {
330                let (staging, counts) = exec_raw::put_dir_exec_raw(exec_raw::PutDirExecRawArgs {
331                    ctx: raw_ctx,
332                    remote_home: ctx.remote_home,
333                    local_src_dir: &ctx.resolved.local_path,
334                    remote_dst_dir: &ctx.response.params.remote_path,
335                    overwrite: ctx.response.params.overwrite,
336                })
337                .await?;
338                ctx.response.staging = Some(staging);
339                ctx.response.counts = Some(counts);
340                ctx.response.semantics = Some(
341                    "directory transfer behavior depends on overwrite: if overwrite=true, it stages into a temp dir (sibling under destination parent when possible, else $HOME/.ssh-mcp/staging) and then swaps into place via rename, optionally moving an existing destination to a backup path removed on success (backup may remain if swap fails); if overwrite=false, it creates the destination directory and writes directly into it (no atomic swap); on upload error it attempts to remove the stage directory (best-effort; for overwrite=false this is the created destination directory, and partial contents may remain)"
342                        .to_string(),
343                );
344                Ok(())
345            }
346        }
347    }
348
349    async fn get(&self, ctx: &mut StepCtx<'_>) -> Result<()> {
350        let raw_ctx = exec_raw::ExecRawCtx {
351            conn: ctx.conn,
352            id: ctx.id,
353            timeout: ctx.timeout,
354        };
355
356        // If the client explicitly provided a kind, validate the remote path kind
357        // before starting any streaming transfer.
358        if ctx.response.params.kind.is_some() {
359            let remote_kind = exec_raw::probe_remote_kind(exec_raw::ProbeRemoteKindArgs {
360                ctx: raw_ctx,
361                remote_path: &ctx.response.params.remote_path,
362            })
363            .await?;
364
365            if remote_kind != ctx.kind {
366                let msg = match ctx.kind {
367                    TransferKind::File => "remote_path is not a file",
368                    TransferKind::Directory => "remote_path is not a directory",
369                };
370                return Err(SshMcpError::invalid_params(msg));
371            }
372        }
373
374        match ctx.kind {
375            TransferKind::File => {
376                let (staging, counts) = exec_raw::get_file_exec_raw(exec_raw::GetFileExecRawArgs {
377                    ctx: raw_ctx,
378                    remote_src: &ctx.response.params.remote_path,
379                    local_dst: &ctx.resolved.local_path,
380                    local_root: self.local_root(),
381                    overwrite: ctx.response.params.overwrite,
382                })
383                .await?;
384                ctx.response.staging = Some(staging);
385                ctx.response.counts = Some(counts);
386                Ok(())
387            }
388            TransferKind::Directory => {
389                let (staging, counts) = exec_raw::get_dir_exec_raw(exec_raw::GetDirExecRawArgs {
390                    ctx: raw_ctx,
391                    remote_src_dir: &ctx.response.params.remote_path,
392                    local_dst_dir: &ctx.resolved.local_path,
393                    local_root: self.local_root(),
394                    overwrite: ctx.response.params.overwrite,
395                })
396                .await?;
397                ctx.response.staging = Some(staging);
398                ctx.response.counts = Some(counts);
399                ctx.response.semantics = Some(
400                    "directory transfer writes into a sibling staging dir under local_root, then swaps into place via rename; local_path must not normalize to '.'; if the destination existed, it is first renamed to a sibling backup path and removed after the swap (backup may remain if swap fails)"
401                        .to_string(),
402                );
403                Ok(())
404            }
405        }
406    }
407
408    async fn run_openssh(
409        &self,
410        ctx: OpenSshContext<'_>,
411        op: OpenSshOperation<'_>,
412    ) -> std::result::Result<(), TransportAttemptError> {
413        let key_path = match ctx.key_path {
414            Some(p) => p,
415            None => {
416                return Err(TransportAttemptError::Unsupported {
417                    transport: match op.transport {
418                        openssh::OpenSshTransport::Sftp => TransferTransport::Sftp,
419                        openssh::OpenSshTransport::Scp => TransferTransport::Scp,
420                    },
421                    reason: "SSH key required for OpenSSH transports (sftp/scp)".to_string(),
422                });
423            }
424        };
425
426        let kind = op.kind;
427        let response = op.response;
428
429        let resolved = self
430            .resolve_and_validate_local_paths(&response.params, kind)
431            .await
432            .map_err(TransportAttemptError::Other)?;
433        response.resolved_paths = Some(resolved.clone());
434
435        // If the client explicitly provided a kind for get, validate the remote path kind
436        // before invoking OpenSSH tooling.
437        let (operation, remote_path, kind_override) = {
438            let params = &response.params;
439            (params.operation, params.remote_path.clone(), params.kind)
440        };
441
442        if matches!(operation, TransferOperation::Get) && kind_override.is_some() {
443            let remote_kind = exec_raw::probe_remote_kind(exec_raw::ProbeRemoteKindArgs {
444                ctx: exec_raw::ExecRawCtx {
445                    conn: ctx.conn,
446                    id: ctx.id,
447                    timeout: ctx.timeout,
448                },
449                remote_path: &remote_path,
450            })
451            .await
452            .map_err(TransportAttemptError::Other)?;
453
454            if remote_kind != kind {
455                let msg = match kind {
456                    TransferKind::File => "remote_path is not a file",
457                    TransferKind::Directory => "remote_path is not a directory",
458                };
459                return Err(TransportAttemptError::Other(SshMcpError::invalid_params(
460                    msg,
461                )));
462            }
463        }
464
465        let endpoint = openssh::OpenSshEndpoint {
466            host: ctx.ssh.host.clone(),
467            port: ctx.ssh.port,
468            user: ctx.ssh.user.clone(),
469            key_path: key_path.to_path_buf(),
470            host_key_checking: ctx.ssh.host_key_checking,
471            known_hosts: ctx.ssh.known_hosts.clone(),
472        };
473
474        let overwrite = response.params.overwrite;
475
476        let openssh_args = openssh::OpenSshTransferArgs {
477            transport: op.transport,
478            conn: ctx.conn,
479            remote_home: ctx.remote_home,
480            local_root: self.local_root(),
481            id: ctx.id,
482            timeout: ctx.timeout,
483            operation,
484            kind,
485            local_path: resolved.local_path,
486            remote_path,
487            overwrite,
488        };
489
490        let (staging, counts) = openssh::run_transfer(endpoint, openssh_args).await?;
491        response.staging = Some(staging);
492        response.counts = Some(counts);
493        if kind == TransferKind::Directory {
494            response.semantics = Some(match operation {
495                TransferOperation::Put => "directory transfer behavior depends on overwrite: if overwrite=true, it stages into a temp dir (sibling under destination parent when possible, else $HOME/.ssh-mcp/staging) and then swaps into place via rename, optionally moving an existing destination to a backup path removed on success (backup may remain if swap fails); if overwrite=false, it creates the destination directory and writes directly into it (no atomic swap); on upload error it attempts to remove the stage directory (best-effort; for overwrite=false this is the created destination directory, and partial contents may remain)".to_string(),
496                TransferOperation::Get => "directory transfer writes into a sibling staging dir under local_root, then swaps into place via rename; local_path must not normalize to '.'; if the destination existed, it is first renamed to a sibling backup path and removed after the swap (backup may remain if swap fails)".to_string(),
497            });
498        }
499        Ok(())
500    }
501
502    async fn run_rsync(
503        &self,
504        ctx: OpenSshContext<'_>,
505        kind: TransferKind,
506        response: &mut TransferResponse,
507    ) -> std::result::Result<(), TransportAttemptError> {
508        let resolved = self
509            .resolve_and_validate_local_paths(&response.params, kind)
510            .await
511            .map_err(TransportAttemptError::Other)?;
512        response.resolved_paths = Some(resolved.clone());
513
514        // If the client explicitly provided a kind for get, validate the remote path kind
515        // before invoking rsync.
516        let (operation, remote_path, kind_override) = {
517            let params = &response.params;
518            (params.operation, params.remote_path.clone(), params.kind)
519        };
520
521        if matches!(operation, TransferOperation::Get) && kind_override.is_some() {
522            let remote_kind = exec_raw::probe_remote_kind(exec_raw::ProbeRemoteKindArgs {
523                ctx: exec_raw::ExecRawCtx {
524                    conn: ctx.conn,
525                    id: ctx.id,
526                    timeout: ctx.timeout,
527                },
528                remote_path: &remote_path,
529            })
530            .await
531            .map_err(TransportAttemptError::Other)?;
532
533            if remote_kind != kind {
534                let msg = match kind {
535                    TransferKind::File => "remote_path is not a file",
536                    TransferKind::Directory => "remote_path is not a directory",
537                };
538                return Err(TransportAttemptError::Other(SshMcpError::invalid_params(
539                    msg,
540                )));
541            }
542        }
543
544        let endpoint = rsync::RsyncEndpoint {
545            host: ctx.ssh.host.clone(),
546            port: ctx.ssh.port,
547            user: ctx.ssh.user.clone(),
548            key_path: ctx.key_path.map(|p| p.to_path_buf()),
549            host_key_checking: ctx.ssh.host_key_checking,
550            known_hosts: ctx.ssh.known_hosts.clone(),
551        };
552
553        let overwrite = response.params.overwrite;
554        let rsync_options = response.params.rsync_options.clone();
555
556        let rsync_args = rsync::RsyncTransferArgs {
557            conn: ctx.conn,
558            remote_home: ctx.remote_home,
559            local_root: self.local_root(),
560            id: ctx.id,
561            timeout: ctx.timeout,
562            operation,
563            kind,
564            local_path: &resolved.local_path,
565            remote_path: &remote_path,
566            overwrite,
567            rsync_options,
568        };
569
570        let (staging, counts) = rsync::run_transfer(endpoint, rsync_args).await?;
571        response.staging = Some(staging);
572        response.counts = Some(counts);
573        if kind == TransferKind::Directory {
574            response.semantics = Some(match operation {
575                TransferOperation::Put => "directory transfer behavior depends on overwrite: if overwrite=true, it stages into a temp dir (sibling under destination parent when possible, else $HOME/.ssh-mcp/staging) and then swaps into place via rename, optionally moving an existing destination to a backup path removed on success (backup may remain if swap fails); if overwrite=false, it creates the destination directory and writes directly into it (no atomic swap); on upload error it attempts to remove the stage directory (best-effort; for overwrite=false this is the created destination directory, and partial contents may remain)".to_string(),
576                TransferOperation::Get => "directory transfer writes into a sibling staging dir under local_root, then swaps into place via rename; local_path must not normalize to '.'; if the destination existed, it is first renamed to a sibling backup path and removed after the swap (backup may remain if swap fails)".to_string(),
577            });
578        }
579        Ok(())
580    }
581
582    async fn resolve_and_validate_local_paths(
583        &self,
584        params: &TransferParams,
585        kind: TransferKind,
586    ) -> Result<ResolvedPaths> {
587        let resolved = local_root::resolve_paths(self.local_root(), params, kind)
588            .map_err(SshMcpError::invalid_params)?;
589
590        if matches!(params.operation, TransferOperation::Get) {
591            local_root::validate_get_target_no_symlinks(self.local_root(), &resolved.local_path)
592                .await
593                .map_err(SshMcpError::invalid_params)?;
594
595            // Create missing parent directories without following symlinks (best-effort).
596            local_root::ensure_parent_dirs_no_symlinks(self.local_root(), &resolved.local_path)
597                .await?;
598        } else {
599            // Best-effort: reject symlink components for put sources to prevent escaping local_root.
600            local_root::validate_put_source_no_symlinks(self.local_root(), &resolved.local_path)
601                .await
602                .map_err(SshMcpError::invalid_params)?;
603        }
604
605        Ok(resolved)
606    }
607}
608
609#[derive(Debug)]
610enum TransportAttemptError {
611    Unsupported {
612        transport: TransferTransport,
613        reason: String,
614    },
615    Other(SshMcpError),
616}
617
618impl std::fmt::Display for TransportAttemptError {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        match self {
621            Self::Unsupported { transport, reason } => {
622                write!(f, "transport {transport:?} unsupported: {reason}")
623            }
624            Self::Other(e) => write!(f, "{e}"),
625        }
626    }
627}
628
629impl std::error::Error for TransportAttemptError {}
630
631async fn resolve_kind(
632    conn: &SshConnectionManager,
633    local_root: &Path,
634    params: &TransferParams,
635    timeout: Duration,
636) -> Result<TransferKind> {
637    match params.kind {
638        Some(kind) => Ok(kind),
639        None => match params.operation {
640            TransferOperation::Put => {
641                let local_src = local_root::safe_join_local_root(local_root, &params.local_path)
642                    .map_err(SshMcpError::invalid_params)?;
643                let meta = tokio::fs::symlink_metadata(&local_src).await?;
644                if meta.is_dir() {
645                    Ok(TransferKind::Directory)
646                } else {
647                    Ok(TransferKind::File)
648                }
649            }
650            TransferOperation::Get => {
651                exec_raw::probe_remote_kind(exec_raw::ProbeRemoteKindArgs {
652                    ctx: exec_raw::ExecRawCtx {
653                        conn,
654                        id: 0,
655                        timeout,
656                    },
657                    remote_path: &params.remote_path,
658                })
659                .await
660            }
661        },
662    }
663}