1#![forbid(unsafe_code)]
4use crate::cli::SftpAction;
11use crate::constants::SFTP_FALLBACK_BASENAME;
12use crate::errors::SshCliError;
13use crate::i18n::{self, Message};
14use crate::output;
15use crate::ssh::client::{SshClient, TransferResult};
16use crate::ssh::sftp_path::{ensure_local_under, validate_entry_name};
17use crate::ssh::sftp_session;
18use crate::ssh::sftp_types::{SftpListEntry, SftpStat};
19use crate::vps;
20use std::path::{Path, PathBuf};
21use std::time::Instant;
22
23pub(crate) mod batch;
24
25#[derive(Debug, Default, Clone)]
27pub struct SftpOptions {
28 pub password: Option<secrecy::SecretString>,
30 pub key: Option<String>,
32 pub key_passphrase: Option<secrecy::SecretString>,
34 pub timeout: Option<crate::domain::TimeoutMs>,
36 pub replace_host_key: bool,
38 pub json: bool,
40 pub use_agent: bool,
42 pub agent_socket: Option<String>,
44 pub recursive: bool,
46}
47
48pub(crate) fn apply_sftp_options(record: &mut crate::vps::model::VpsRecord, opts: &SftpOptions) {
50 if let Some(ref pwd) = opts.password {
51 record.password = pwd.clone();
52 }
53 if let Some(ref k) = opts.key {
54 if let Ok(kp) = crate::domain::KeyPath::try_new(k.as_str()) {
55 record.key_path = Some(kp);
56 }
57 }
58 if let Some(ref kp) = opts.key_passphrase {
59 record.key_passphrase = Some(kp.clone());
60 }
61 if let Some(t) = opts.timeout {
62 record.timeout_ms = t;
63 }
64 if opts.use_agent {
65 record.use_agent = true;
66 }
67 if let Some(ref sock) = opts.agent_socket {
68 record.agent_socket = Some(sock.clone());
69 record.use_agent = true;
70 }
71}
72
73async fn connect_client(
74 vps_key: &str,
75 config_override: Option<&std::path::Path>,
76 opts: &SftpOptions,
77) -> anyhow::Result<SshClient> {
78 let mut record = vps::find_by_name(config_override, vps_key)?
79 .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
80 apply_sftp_options(&mut record, opts);
81 let path = vps::resolve_config_path(config_override)?;
82 let cfg = vps::build_connection_config(&record, Some(&path), opts.replace_host_key);
83 let client = SshClient::connect(cfg).await?;
84 Ok(client)
85}
86
87fn remote_str(p: &Path) -> String {
88 p.to_string_lossy().into_owned()
89}
90
91pub async fn run_sftp(
93 action: SftpAction,
94 config_override: Option<PathBuf>,
95 opts: SftpOptions,
96) -> anyhow::Result<()> {
97 if crate::signals::should_stop() {
98 return Err(anyhow::anyhow!(i18n::t(Message::OperationCancelled)));
99 }
100
101 match action {
102 SftpAction::Upload {
103 all,
104 hosts,
105 target,
106 recursive,
107 ..
108 } => {
109 let mut opts = opts;
110 opts.recursive = recursive;
111 let plan = crate::cli::parse_scp_target(all, hosts, target)
112 .map_err(SshCliError::InvalidArgument)?;
113 match plan {
114 crate::cli::ScpPathPlan::Single {
115 selection,
116 path_a: local,
117 path_b: remote,
118 } => {
119 if selection.is_batch() {
120 return batch::run_sftp_all_upload(
121 &selection,
122 &local,
123 &remote_str(&remote),
124 config_override,
125 opts,
126 )
127 .await;
128 }
129 let vps::HostSelection::Single(vps_name) = selection else {
130 return Err(SshCliError::InvalidArgument(
131 "internal: expected single-host sftp upload".into(),
132 )
133 .into());
134 };
135 let client =
136 connect_client(vps_name.as_str(), config_override.as_deref(), &opts)
137 .await?;
138 let remote = remote_str(&remote);
139 let result = if opts.recursive {
140 client.sftp_upload_tree(&local, &remote).await
141 } else {
142 client.sftp_upload(&local, &remote).await
143 };
144 let _ = client.disconnect().await;
145 emit_transfer(
146 "upload",
147 vps_name.as_str(),
148 &local.display().to_string(),
149 &remote,
150 result?,
151 opts.json,
152 opts.recursive,
153 )?;
154 }
155 crate::cli::ScpPathPlan::MultiFile {
156 vps,
157 sources,
158 dest_dir,
159 } => {
160 if opts.recursive {
161 return Err(SshCliError::InvalidArgument(
162 "sftp multi-file upload does not combine with --recursive".into(),
163 )
164 .into());
165 }
166 let client =
167 connect_client(vps.as_str(), config_override.as_deref(), &opts).await?;
168 let dest = remote_str(&dest_dir);
169 let local_label = sources
170 .first()
171 .map(|p| p.display().to_string())
172 .unwrap_or_default();
173 let start = Instant::now();
174 let timeout_ms = client.timeout_ms();
175 let result = sftp_session::under_timeout(timeout_ms, async {
176 let sftp = client.open_sftp().await?;
177 let mut bytes = 0_u64;
178 let mut err: Option<SshCliError> = None;
179 for src in &sources {
180 let name = src
181 .file_name()
182 .map(|n| n.to_string_lossy().into_owned())
183 .unwrap_or_else(|| SFTP_FALLBACK_BASENAME.to_owned());
184 validate_entry_name(&name)?;
185 let remote = crate::ssh::sftp_path::join_remote(&dest, &name);
186 match sftp_session::upload_file(&sftp, src, &remote).await {
187 Ok(r) => bytes = bytes.saturating_add(r.bytes_transferred),
188 Err(e) => {
189 err = Some(e);
190 break;
191 }
192 }
193 }
194 sftp_session::close_sftp(&sftp).await;
195 if let Some(e) = err {
196 return Err(e);
197 }
198 Ok(bytes)
199 })
200 .await;
201 let _ = client.disconnect().await;
202 let bytes = result?;
203 emit_transfer(
204 "upload",
205 vps.as_str(),
206 &local_label,
207 &dest,
208 TransferResult {
209 bytes_transferred: bytes,
210 duration_ms: u64::try_from(start.elapsed().as_millis())
211 .unwrap_or(u64::MAX),
212 },
213 opts.json,
214 false,
215 )?;
216 }
217 crate::cli::ScpPathPlan::MultiHostMultiFile {
218 selection,
219 sources,
220 dest_dir,
221 } => {
222 return batch::run_sftp_multi_host_multi_file_upload(
223 &selection,
224 sources,
225 &remote_str(&dest_dir),
226 config_override,
227 opts,
228 )
229 .await;
230 }
231 }
232 }
233 SftpAction::Download {
234 all,
235 hosts,
236 target,
237 recursive,
238 ..
239 } => {
240 let mut opts = opts;
241 opts.recursive = recursive;
242 let plan = crate::cli::parse_scp_target(all, hosts, target)
243 .map_err(SshCliError::InvalidArgument)?;
244 match plan {
245 crate::cli::ScpPathPlan::Single {
246 selection,
247 path_a: remote,
248 path_b: local,
249 } => {
250 if selection.is_batch() {
251 return batch::run_sftp_all_download(
252 &selection,
253 &remote_str(&remote),
254 &local,
255 config_override,
256 opts,
257 )
258 .await;
259 }
260 let vps::HostSelection::Single(vps_name) = selection else {
261 return Err(SshCliError::InvalidArgument(
262 "internal: expected single-host sftp download".into(),
263 )
264 .into());
265 };
266 let client =
267 connect_client(vps_name.as_str(), config_override.as_deref(), &opts)
268 .await?;
269 let remote = remote_str(&remote);
270 let result = if opts.recursive {
271 client.sftp_download_tree(&remote, &local).await
272 } else {
273 client.sftp_download(&remote, &local).await
274 };
275 let _ = client.disconnect().await;
276 emit_transfer(
277 "download",
278 vps_name.as_str(),
279 &local.display().to_string(),
280 &remote,
281 result?,
282 opts.json,
283 opts.recursive,
284 )?;
285 }
286 crate::cli::ScpPathPlan::MultiFile {
287 vps,
288 sources: remotes,
289 dest_dir: local_dir,
290 } => {
291 if opts.recursive {
292 return Err(SshCliError::InvalidArgument(
293 "sftp multi-file download does not combine with --recursive".into(),
294 )
295 .into());
296 }
297 let client =
298 connect_client(vps.as_str(), config_override.as_deref(), &opts).await?;
299 let local_label = local_dir.display().to_string();
300 let remote_label = remotes
301 .first()
302 .map(|p| p.display().to_string())
303 .unwrap_or_default();
304 let start = Instant::now();
305 let timeout_ms = client.timeout_ms();
306 let local_root = local_dir.clone();
307 let result = sftp_session::under_timeout(timeout_ms, async {
308 tokio::fs::create_dir_all(&local_dir)
309 .await
310 .map_err(SshCliError::Io)?;
311 let sftp = client.open_sftp().await?;
312 let mut bytes = 0_u64;
313 let mut err: Option<SshCliError> = None;
314 for remote_p in &remotes {
315 let remote = remote_str(remote_p);
316 let name = remote_p
317 .file_name()
318 .map(|n| n.to_string_lossy().into_owned())
319 .unwrap_or_else(|| SFTP_FALLBACK_BASENAME.to_owned());
320 if let Err(e) = validate_entry_name(&name) {
321 err = Some(e);
322 break;
323 }
324 let local = local_dir.join(&name);
325 if let Err(e) = ensure_local_under(&local_root, &local) {
326 err = Some(e);
327 break;
328 }
329 match sftp_session::download_file(&sftp, &remote, &local).await {
330 Ok(r) => bytes = bytes.saturating_add(r.bytes_transferred),
331 Err(e) => {
332 err = Some(e);
333 break;
334 }
335 }
336 }
337 sftp_session::close_sftp(&sftp).await;
338 if let Some(e) = err {
339 return Err(e);
340 }
341 Ok(bytes)
342 })
343 .await;
344 let _ = client.disconnect().await;
345 let bytes = result?;
346 emit_transfer(
347 "download",
348 vps.as_str(),
349 &local_label,
350 &remote_label,
351 TransferResult {
352 bytes_transferred: bytes,
353 duration_ms: u64::try_from(start.elapsed().as_millis())
354 .unwrap_or(u64::MAX),
355 },
356 opts.json,
357 false,
358 )?;
359 }
360 crate::cli::ScpPathPlan::MultiHostMultiFile {
361 selection,
362 sources: remotes,
363 dest_dir: local_dir,
364 } => {
365 return batch::run_sftp_multi_host_multi_file_download(
366 &selection,
367 remotes,
368 &local_dir,
369 config_override,
370 opts,
371 )
372 .await;
373 }
374 }
375 }
376 SftpAction::Ls {
377 vps_name,
378 remote,
379 json: json_local,
380 ..
381 } => {
382 let json = opts.json || json_local;
383 let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
384 let timeout_ms = client.timeout_ms();
385 let entries = sftp_session::under_timeout(timeout_ms, async {
386 let sftp = client.open_sftp().await?;
387 let entries = sftp_session::list_dir(&sftp, &remote).await;
388 sftp_session::close_sftp(&sftp).await;
389 entries
390 })
391 .await;
392 let _ = client.disconnect().await;
393 emit_list(&vps_name, &remote, &entries?, json)?;
394 }
395 SftpAction::Mkdir {
396 vps_name,
397 remote,
398 json: json_local,
399 ..
400 } => {
401 let json = opts.json || json_local;
402 let start = Instant::now();
403 let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
404 let timeout_ms = client.timeout_ms();
405 let result = sftp_session::under_timeout(timeout_ms, async {
406 let sftp = client.open_sftp().await?;
407 let result = sftp_session::mkdir(&sftp, &remote).await;
408 sftp_session::close_sftp(&sftp).await;
409 result
410 })
411 .await;
412 let _ = client.disconnect().await;
413 result?;
414 emit_fs_op(
415 "mkdir",
416 &vps_name,
417 &remote,
418 None,
419 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
420 json,
421 )?;
422 }
423 SftpAction::Rmdir {
424 vps_name,
425 remote,
426 json: json_local,
427 ..
428 } => {
429 let json = opts.json || json_local;
430 let start = Instant::now();
431 let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
432 let timeout_ms = client.timeout_ms();
433 let result = sftp_session::under_timeout(timeout_ms, async {
434 let sftp = client.open_sftp().await?;
435 let result = sftp_session::rmdir(&sftp, &remote).await;
436 sftp_session::close_sftp(&sftp).await;
437 result
438 })
439 .await;
440 let _ = client.disconnect().await;
441 result?;
442 emit_fs_op(
443 "rmdir",
444 &vps_name,
445 &remote,
446 None,
447 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
448 json,
449 )?;
450 }
451 SftpAction::Rm {
452 vps_name,
453 remote,
454 json: json_local,
455 ..
456 } => {
457 let json = opts.json || json_local;
458 let start = Instant::now();
459 let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
460 let timeout_ms = client.timeout_ms();
461 let result = sftp_session::under_timeout(timeout_ms, async {
462 let sftp = client.open_sftp().await?;
463 let result = sftp_session::rm(&sftp, &remote).await;
464 sftp_session::close_sftp(&sftp).await;
465 result
466 })
467 .await;
468 let _ = client.disconnect().await;
469 result?;
470 emit_fs_op(
471 "rm",
472 &vps_name,
473 &remote,
474 None,
475 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
476 json,
477 )?;
478 }
479 SftpAction::Stat {
480 vps_name,
481 remote,
482 json: json_local,
483 ..
484 } => {
485 let json = opts.json || json_local;
486 let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
487 let timeout_ms = client.timeout_ms();
488 let st = sftp_session::under_timeout(timeout_ms, async {
489 let sftp = client.open_sftp().await?;
490 let st = sftp_session::stat(&sftp, &remote).await;
491 sftp_session::close_sftp(&sftp).await;
492 st
493 })
494 .await;
495 let _ = client.disconnect().await;
496 emit_stat(&vps_name, &st?, json)?;
497 }
498 SftpAction::Rename {
499 vps_name,
500 from,
501 to,
502 json: json_local,
503 ..
504 } => {
505 let json = opts.json || json_local;
506 let start = Instant::now();
507 let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
508 let timeout_ms = client.timeout_ms();
509 let result = sftp_session::under_timeout(timeout_ms, async {
510 let sftp = client.open_sftp().await?;
511 let result = sftp_session::rename(&sftp, &from, &to).await;
512 sftp_session::close_sftp(&sftp).await;
513 result
514 })
515 .await;
516 let _ = client.disconnect().await;
517 result?;
518 emit_fs_op(
519 "rename",
520 &vps_name,
521 &from,
522 Some(to.as_str()),
523 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
524 json,
525 )?;
526 }
527 }
528 Ok(())
529}
530
531fn emit_transfer(
532 direction: &str,
533 vps: &str,
534 local: &str,
535 remote: &str,
536 result: TransferResult,
537 json: bool,
538 recursive: bool,
539) -> anyhow::Result<()> {
540 if json {
541 output::print_sftp_transfer_json(
542 direction,
543 vps,
544 local,
545 remote,
546 result.bytes_transferred,
547 result.duration_ms,
548 recursive,
549 )?;
550 } else {
551 let msg = if direction == "upload" {
552 Message::SftpUploadCompleted {
553 bytes: result.bytes_transferred,
554 ms: result.duration_ms,
555 }
556 } else {
557 Message::SftpDownloadCompleted {
558 bytes: result.bytes_transferred,
559 ms: result.duration_ms,
560 }
561 };
562 output::print_success(&i18n::t(msg));
563 }
564 Ok(())
565}
566
567fn emit_list(vps: &str, path: &str, entries: &[SftpListEntry], json: bool) -> anyhow::Result<()> {
568 if json {
569 output::print_sftp_list_json(vps, path, entries)?;
570 } else {
571 for e in entries {
572 println!(
573 "{}\t{}\t{}",
574 e.kind,
575 e.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
576 e.path
577 );
578 }
579 }
580 Ok(())
581}
582
583fn emit_stat(vps: &str, st: &SftpStat, json: bool) -> anyhow::Result<()> {
584 if json {
585 output::print_sftp_stat_json(vps, st)?;
586 } else {
587 println!(
588 "path={} kind={} size={} mode={:?} mtime={:?}",
589 st.path,
590 st.kind,
591 st.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
592 st.mode,
593 st.mtime
594 );
595 }
596 Ok(())
597}
598
599fn emit_fs_op(
600 op: &str,
601 vps: &str,
602 path: &str,
603 to: Option<&str>,
604 duration_ms: u64,
605 json: bool,
606) -> anyhow::Result<()> {
607 if json {
608 output::print_sftp_fs_op_json(op, vps, path, to, duration_ms)?;
609 } else {
610 match to {
611 Some(t) => {
612 output::print_success(&format!("sftp {op} ok: {path} -> {t} ({duration_ms}ms)"))
613 }
614 None => output::print_success(&format!("sftp {op} ok: {path} ({duration_ms}ms)")),
615 }
616 }
617 Ok(())
618}