1mod 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#[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, TransferTransport::Sftp, TransferTransport::Scp, TransferTransport::ExecRaw, ]
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 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 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 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 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 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 local_root::ensure_parent_dirs_no_symlinks(self.local_root(), &resolved.local_path)
597 .await?;
598 } else {
599 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, ¶ms.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: ¶ms.remote_path,
658 })
659 .await
660 }
661 },
662 }
663}