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 =
384 connect_client(&vps_name, config_override.as_deref(), &opts).await?;
385 let timeout_ms = client.timeout_ms();
386 let entries = sftp_session::under_timeout(timeout_ms, async {
387 let sftp = client.open_sftp().await?;
388 let entries = sftp_session::list_dir(&sftp, &remote).await;
389 sftp_session::close_sftp(&sftp).await;
390 entries
391 })
392 .await;
393 let _ = client.disconnect().await;
394 emit_list(&vps_name, &remote, &entries?, json)?;
395 }
396 SftpAction::Mkdir {
397 vps_name,
398 remote,
399 json: json_local,
400 ..
401 } => {
402 let json = opts.json || json_local;
403 let start = Instant::now();
404 let client =
405 connect_client(&vps_name, config_override.as_deref(), &opts).await?;
406 let timeout_ms = client.timeout_ms();
407 let result = sftp_session::under_timeout(timeout_ms, async {
408 let sftp = client.open_sftp().await?;
409 let result = sftp_session::mkdir(&sftp, &remote).await;
410 sftp_session::close_sftp(&sftp).await;
411 result
412 })
413 .await;
414 let _ = client.disconnect().await;
415 result?;
416 emit_fs_op(
417 "mkdir",
418 &vps_name,
419 &remote,
420 None,
421 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
422 json,
423 )?;
424 }
425 SftpAction::Rmdir {
426 vps_name,
427 remote,
428 json: json_local,
429 ..
430 } => {
431 let json = opts.json || json_local;
432 let start = Instant::now();
433 let client =
434 connect_client(&vps_name, config_override.as_deref(), &opts).await?;
435 let timeout_ms = client.timeout_ms();
436 let result = sftp_session::under_timeout(timeout_ms, async {
437 let sftp = client.open_sftp().await?;
438 let result = sftp_session::rmdir(&sftp, &remote).await;
439 sftp_session::close_sftp(&sftp).await;
440 result
441 })
442 .await;
443 let _ = client.disconnect().await;
444 result?;
445 emit_fs_op(
446 "rmdir",
447 &vps_name,
448 &remote,
449 None,
450 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
451 json,
452 )?;
453 }
454 SftpAction::Rm {
455 vps_name,
456 remote,
457 json: json_local,
458 ..
459 } => {
460 let json = opts.json || json_local;
461 let start = Instant::now();
462 let client =
463 connect_client(&vps_name, config_override.as_deref(), &opts).await?;
464 let timeout_ms = client.timeout_ms();
465 let result = sftp_session::under_timeout(timeout_ms, async {
466 let sftp = client.open_sftp().await?;
467 let result = sftp_session::rm(&sftp, &remote).await;
468 sftp_session::close_sftp(&sftp).await;
469 result
470 })
471 .await;
472 let _ = client.disconnect().await;
473 result?;
474 emit_fs_op(
475 "rm",
476 &vps_name,
477 &remote,
478 None,
479 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
480 json,
481 )?;
482 }
483 SftpAction::Stat {
484 vps_name,
485 remote,
486 json: json_local,
487 ..
488 } => {
489 let json = opts.json || json_local;
490 let client =
491 connect_client(&vps_name, config_override.as_deref(), &opts).await?;
492 let timeout_ms = client.timeout_ms();
493 let st = sftp_session::under_timeout(timeout_ms, async {
494 let sftp = client.open_sftp().await?;
495 let st = sftp_session::stat(&sftp, &remote).await;
496 sftp_session::close_sftp(&sftp).await;
497 st
498 })
499 .await;
500 let _ = client.disconnect().await;
501 emit_stat(&vps_name, &st?, json)?;
502 }
503 SftpAction::Rename {
504 vps_name,
505 from,
506 to,
507 json: json_local,
508 ..
509 } => {
510 let json = opts.json || json_local;
511 let start = Instant::now();
512 let client =
513 connect_client(&vps_name, config_override.as_deref(), &opts).await?;
514 let timeout_ms = client.timeout_ms();
515 let result = sftp_session::under_timeout(timeout_ms, async {
516 let sftp = client.open_sftp().await?;
517 let result = sftp_session::rename(&sftp, &from, &to).await;
518 sftp_session::close_sftp(&sftp).await;
519 result
520 })
521 .await;
522 let _ = client.disconnect().await;
523 result?;
524 emit_fs_op(
525 "rename",
526 &vps_name,
527 &from,
528 Some(to.as_str()),
529 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
530 json,
531 )?;
532 }
533 }
534 Ok(())
535}
536
537fn emit_transfer(
538 direction: &str,
539 vps: &str,
540 local: &str,
541 remote: &str,
542 result: TransferResult,
543 json: bool,
544 recursive: bool,
545) -> anyhow::Result<()> {
546 if json {
547 output::print_sftp_transfer_json(
548 direction,
549 vps,
550 local,
551 remote,
552 result.bytes_transferred,
553 result.duration_ms,
554 recursive,
555 )?;
556 } else {
557 let msg = if direction == "upload" {
558 Message::SftpUploadCompleted {
559 bytes: result.bytes_transferred,
560 ms: result.duration_ms,
561 }
562 } else {
563 Message::SftpDownloadCompleted {
564 bytes: result.bytes_transferred,
565 ms: result.duration_ms,
566 }
567 };
568 output::print_success(&i18n::t(msg));
569 }
570 Ok(())
571}
572
573fn emit_list(vps: &str, path: &str, entries: &[SftpListEntry], json: bool) -> anyhow::Result<()> {
574 if json {
575 output::print_sftp_list_json(vps, path, entries)?;
576 } else {
577 for e in entries {
578 println!(
579 "{}\t{}\t{}",
580 e.kind,
581 e.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
582 e.path
583 );
584 }
585 }
586 Ok(())
587}
588
589fn emit_stat(vps: &str, st: &SftpStat, json: bool) -> anyhow::Result<()> {
590 if json {
591 output::print_sftp_stat_json(vps, st)?;
592 } else {
593 println!(
594 "path={} kind={} size={} mode={:?} mtime={:?}",
595 st.path,
596 st.kind,
597 st.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
598 st.mode,
599 st.mtime
600 );
601 }
602 Ok(())
603}
604
605fn emit_fs_op(
606 op: &str,
607 vps: &str,
608 path: &str,
609 to: Option<&str>,
610 duration_ms: u64,
611 json: bool,
612) -> anyhow::Result<()> {
613 if json {
614 output::print_sftp_fs_op_json(op, vps, path, to, duration_ms)?;
615 } else {
616 match to {
617 Some(t) => output::print_success(&format!("sftp {op} ok: {path} -> {t} ({duration_ms}ms)")),
618 None => output::print_success(&format!("sftp {op} ok: {path} ({duration_ms}ms)")),
619 }
620 }
621 Ok(())
622}
623
624