1use async_trait::async_trait;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use tracing::{debug, error, info, warn};
27
28use crate::constants;
29use crate::engine::command::{Command, CommandStatus};
30use crate::error::{Aria2Error, FatalError, RecoverableError, Result};
31use crate::filesystem::disk_writer::{DefaultDiskWriter, DiskWriter};
32use crate::rate_limiter::{RateLimiter, RateLimiterConfig, ThrottledWriter};
33use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
34
35use aria2_protocol::sftp::connection::{HostKeyCheckingMode, SshConnection, SshError, SshOptions};
37use aria2_protocol::sftp::file_ops::{FileOpError, OpenFlags};
38use aria2_protocol::sftp::session::SftpSession;
39
40pub struct SftpDownloadCommand {
51 group: Arc<tokio::sync::RwLock<RequestGroup>>,
53 output_path: std::path::PathBuf,
55 started: bool,
57 completed_bytes: u64,
59 host: String,
61 port: u16,
63 username: String,
65 password: Option<String>,
67 remote_path: String,
69}
70
71impl SftpDownloadCommand {
72 pub fn new(
91 gid: GroupId,
92 uri: &str,
93 options: &DownloadOptions,
94 output_dir: Option<&str>,
95 output_name: Option<&str>,
96 ) -> Result<Self> {
97 let (host, port, username, password, remote_path) = Self::parse_uri(uri)?;
99
100 let dir = output_dir
102 .map(|d| d.to_string())
103 .or_else(|| options.dir.clone())
104 .unwrap_or_else(|| constants::DEFAULT_OUTPUT_DIR.to_string());
105
106 let filename = output_name
108 .map(|n| n.to_string())
109 .or_else(|| Self::extract_filename(&remote_path))
110 .unwrap_or_else(|| constants::DEFAULT_FILENAME.to_string());
111
112 let path = std::path::PathBuf::from(&dir).join(&filename);
114
115 let group = RequestGroup::new(gid, vec![uri.to_string()], options.clone());
117
118 info!(
119 "[SFTP-CMD] Created download command: {} -> {} ({}@{}:{}/{})",
120 uri,
121 path.display(),
122 username,
123 host,
124 port,
125 remote_path
126 );
127
128 Ok(Self {
129 group: Arc::new(tokio::sync::RwLock::new(group)),
130 output_path: path,
131 started: false,
132 completed_bytes: 0,
133 host,
134 port,
135 username,
136 password,
137 remote_path,
138 })
139 }
140
141 fn parse_uri(uri: &str) -> Result<(String, u16, String, Option<String>, String)> {
149 if !uri.starts_with("sftp://") {
150 return Err(Aria2Error::Fatal(FatalError::UnsupportedProtocol {
151 protocol: "sftp".into(),
152 }));
153 }
154
155 let without_scheme = uri.trim_start_matches("sftp://");
156
157 let (auth_host_port, path) = match without_scheme.find('/') {
159 Some(idx) => (&without_scheme[..idx], &without_scheme[idx..]),
160 None => (without_scheme, "/"),
161 };
162
163 let (username, rest) = match auth_host_port.find('@') {
165 Some(idx) => (&auth_host_port[..idx], &auth_host_port[idx + 1..]),
166 None => {
167 let user = std::env::var("USER").unwrap_or_else(|_| "root".to_string());
169 return Ok((
170 user.to_string(),
171 constants::SFTP_DEFAULT_PORT,
172 user,
173 None,
174 "/".to_string(),
175 ));
176 }
177 };
178
179 let password = username.split(':').nth(1).map(|p| p.to_string());
181 let clean_user = username.split(':').next().unwrap_or(username).to_string();
182
183 let (host, port) = match rest.rfind(':') {
185 Some(idx) => (
186 rest[..idx].to_string(),
187 rest[idx + 1..]
188 .parse::<u16>()
189 .unwrap_or(constants::SFTP_DEFAULT_PORT),
190 ),
191 None => (rest.to_string(), constants::SFTP_DEFAULT_PORT),
192 };
193
194 Ok((host, port, clean_user, password, sftp_path_decode(path)))
195 }
196
197 fn extract_filename(remote_path: &str) -> Option<String> {
199 remote_path
200 .rsplit('/')
201 .next()
202 .filter(|s| !s.is_empty() && *s != "/")
203 .map(|s| s.to_string())
204 }
205
206 pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
208 self.group.read().await
209 }
210
211 fn build_ssh_options(&self) -> SshOptions {
213 let mut opts = SshOptions::new(&self.host, &self.username)
214 .with_port(self.port)
215 .with_timeouts(
216 Duration::from_secs(constants::SFTP_CONNECT_TIMEOUT_SECS),
217 Duration::from_secs(constants::SFTP_READ_TIMEOUT_SECS),
218 )
219 .with_host_key_mode(HostKeyCheckingMode::AcceptNew);
220
221 if let Some(ref pwd) = self.password {
222 opts = opts.with_password(pwd);
223 }
224
225 opts
226 }
227
228 fn map_ssh_error(err: &SshError, host: &str, port: u16, _path: &str) -> Aria2Error {
230 match err {
231 SshError::AuthFailed { .. } => Aria2Error::Fatal(FatalError::PermissionDenied {
232 path: format!("{}:{}", host, port),
233 }),
234 SshError::ConnectTimeout { .. } | SshError::ConnectFailed { .. } => {
235 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
236 message: err.to_string(),
237 })
238 }
239 SshError::Handshake { .. } | SshError::ConnectionLost { .. } => {
240 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
241 message: err.to_string(),
242 })
243 }
244 SshError::NoCredentials { .. } => Aria2Error::Fatal(FatalError::Config(format!(
245 "No SSH credentials provided for {}:{}",
246 host, port
247 ))),
248 _ => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
249 message: format!("SFTP error [{}:{}]: {}", host, port, err),
250 }),
251 }
252 }
253
254 fn map_file_op_error(err: &FileOpError, host: &str, path: &str) -> Aria2Error {
256 match err {
257 FileOpError::NotFound { .. } => Aria2Error::Fatal(FatalError::FileNotFound {
258 path: path.to_string(),
259 }),
260 FileOpError::PermissionDenied { .. } => {
261 Aria2Error::Fatal(FatalError::PermissionDenied {
262 path: format!("{}:{}", host, path),
263 })
264 }
265 FileOpError::Network { .. } => {
266 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
267 message: err.to_string(),
268 })
269 }
270 _ => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
271 message: format!("SFTP file op error: {}", err),
272 }),
273 }
274 }
275}
276
277fn sftp_path_decode(s: &str) -> String {
281 let mut bytes = Vec::with_capacity(s.len());
282 let mut chars = s.chars().peekable();
283 while let Some(c) = chars.next() {
284 if c == '%' {
285 let hex: String = chars.by_ref().take(2).collect();
286 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
287 bytes.push(byte);
288 } else {
289 bytes.extend_from_slice(c.to_string().as_bytes());
291 bytes.extend_from_slice(hex.as_bytes());
292 }
293 } else {
294 bytes.push(c as u8);
295 }
296 }
297 String::from_utf8_lossy(&bytes).into_owned()
299}
300
301#[async_trait]
302impl Command for SftpDownloadCommand {
303 async fn execute(&mut self) -> Result<()> {
309 if !self.started {
313 self.group.write().await.start().await?;
314 self.started = true;
315 }
316
317 debug!(
318 "[SFTP-CMD] Starting download: {}@{}:{} -> {}",
319 self.username,
320 self.host,
321 self.port,
322 self.output_path.display()
323 );
324
325 if let Some(parent) = self.output_path.parent()
327 && !parent.as_os_str().is_empty()
328 && !parent.exists()
329 {
330 std::fs::create_dir_all(parent).map_err(|e| {
331 Aria2Error::Fatal(FatalError::Config(format!(
332 "Failed to create output directory: {}",
333 e
334 )))
335 })?;
336 }
337
338 let ssh_options = self.build_ssh_options();
342 let conn_result = SshConnection::connect(ssh_options.clone()).await;
343
344 let mut conn = match conn_result {
345 Ok(c) => c,
346 Err(e) => {
347 return Err(Self::map_ssh_error(
348 &e,
349 &self.host,
350 self.port,
351 &self.remote_path,
352 ));
353 }
354 };
355
356 info!(
357 "[SFTP-CMD] SSH connected: {}@{}:{}",
358 self.username, self.host, self.port
359 );
360
361 let session_result = SftpSession::open(&mut conn).await;
365
366 let session: aria2_protocol::sftp::session::SftpSession = match session_result {
367 Ok(s) => s,
368 Err(e) => {
369 let _ = conn.disconnect().await;
371 return Err(Aria2Error::Recoverable(
372 RecoverableError::TemporaryNetworkFailure {
373 message: format!("SFTP session init failed: {}", e),
374 },
375 ));
376 }
377 };
378
379 debug!(
380 "[SFTP-CMD] SFTP session established (v{})",
381 session.server_version()
382 );
383
384 use aria2_protocol::sftp::file_ops::SftpFileOps;
388 let ops = SftpFileOps::new(&session);
389
390 let file_attrs = match ops.stat(&self.remote_path).await {
391 Ok(attrs) => attrs,
392 Err(e) => {
393 let _ = conn.disconnect().await;
394 return Err(Self::map_file_op_error(&e, &self.host, &self.remote_path));
395 }
396 };
397
398 if !file_attrs.is_regular_file {
399 let _ = conn.disconnect().await;
400 return Err(Aria2Error::Fatal(FatalError::FileNotFound {
401 path: format!("{} (not a regular file)", self.remote_path),
402 }));
403 }
404
405 let total_length = file_attrs.size;
406 info!(
407 "[SFTP-CMD] Remote file size: {} bytes ({:.2} MB)",
408 total_length,
409 total_length as f64 / (1024.0 * 1024.0)
410 );
411
412 {
414 let mut g = self.group.write().await;
415 g.set_total_length(total_length).await;
416 }
417
418 let remote_file: aria2_protocol::sftp::file_ops::SftpFileHandle =
422 match ops.open(&self.remote_path, OpenFlags::readonly(), 0).await {
423 Ok(f) => f,
424 Err(e) => {
425 let _ = conn.disconnect().await;
426 return Err(Self::map_file_op_error(&e, &self.host, &self.remote_path));
427 }
428 };
429
430 let raw_writer = DefaultDiskWriter::new(&self.output_path);
434
435 let rate_limit = {
437 let g = self.group.read().await;
438 g.options().max_download_limit
439 };
440 let mut writer: Box<dyn DiskWriter> = match rate_limit {
441 Some(rate) if rate > 0 => Box::new(ThrottledWriter::new(
442 raw_writer,
443 RateLimiter::new(&RateLimiterConfig::new(Some(rate), None)),
444 )),
445 _ => Box::new(raw_writer),
446 };
447
448 let start_time = Instant::now();
452 let mut last_speed_update = Instant::now();
453 let mut last_completed: u64 = 0;
454 let _buf = vec![0u8; constants::SFTP_DISK_WRITE_CHUNK_SIZE];
455
456 info!("[SFTP-CMD] Starting transfer loop: {} bytes", total_length);
457
458 loop {
459 let remaining = total_length.saturating_sub(self.completed_bytes);
460 if remaining == 0 {
461 break; }
463
464 let to_read = (constants::SFTP_DISK_WRITE_CHUNK_SIZE as u64).min(remaining) as usize;
466
467 let data = match remote_file
469 .read_at(self.completed_bytes, to_read as u32)
470 .await
471 {
472 Ok(data) if data.is_empty() => {
473 debug!(
474 "[SFTP-CMD] EOF at offset {} (expected {})",
475 self.completed_bytes, total_length
476 );
477 break;
478 }
479 Ok(data) => data,
480 Err(e) => {
481 error!(
482 "[SFTP-CMD] Read error at offset {}: {}",
483 self.completed_bytes, e
484 );
485 let _ = remote_file.close().await;
486 let _ = conn.disconnect().await;
487 return Err(Self::map_file_op_error(&e, &self.host, &self.remote_path));
488 }
489 };
490 let n = data.len();
491
492 if let Err(e) = writer.write(&data).await {
494 error!("[SFTP-CMD] Disk write error: {}", e);
495 let _ = remote_file.close().await;
496 let _ = conn.disconnect().await;
497 return Err(Aria2Error::Fatal(FatalError::Config(format!(
498 "Disk write failed: {}",
499 e
500 ))));
501 }
502
503 self.completed_bytes += n as u64;
504
505 {
507 let g = self.group.write().await;
508 g.update_progress(self.completed_bytes).await;
509
510 let elapsed = last_speed_update.elapsed();
512 if elapsed.as_millis() >= constants::SFTP_SPEED_UPDATE_INTERVAL_MS as u128 {
513 let delta = self.completed_bytes - last_completed;
514 let speed = (delta as f64 / elapsed.as_secs_f64()) as u64;
515 g.update_speed(speed, 0).await;
516 last_speed_update = Instant::now();
517 last_completed = self.completed_bytes;
518 }
519 }
520 }
521
522 if let Err(e) = remote_file.close().await {
528 warn!("[SFTP-CMD] Warning closing remote file: {}", e);
529 }
530
531 if let Err(e) = writer.finalize().await {
533 warn!("[SFTP-CMD] Warning finalizing disk writer: {}", e);
534 }
535
536 if let Err(e) = conn.disconnect().await {
538 warn!("[SFTP-CMD] Warning during SSH disconnect: {}", e);
539 }
540
541 let final_speed = {
543 let elapsed = start_time.elapsed().as_secs_f64();
544 if elapsed > 0.0 {
545 (self.completed_bytes as f64 / elapsed) as u64
546 } else {
547 0
548 }
549 };
550
551 {
553 let mut g = self.group.write().await;
554 g.update_progress(self.completed_bytes).await;
555 g.update_speed(final_speed, 0).await;
556 g.complete().await?;
557 }
558
559 info!(
560 "[SFTP-CMD] Download complete: {} ({} bytes, {:.1} KB/s)",
561 self.output_path.display(),
562 self.completed_bytes,
563 final_speed as f64 / 1024.0
564 );
565
566 Ok(())
567 }
568
569 fn status(&self) -> CommandStatus {
571 if self.completed_bytes > 0 {
572 CommandStatus::Running
573 } else {
574 CommandStatus::Pending
575 }
576 }
577
578 fn timeout(&self) -> Option<Duration> {
580 Some(Duration::from_secs(constants::SFTP_COMMAND_TIMEOUT_SECS))
581 }
582}
583
584#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn test_sftp_path_decoding() {
594 assert_eq!(sftp_path_decode("/normal/path"), "/normal/path");
595 assert_eq!(
596 sftp_path_decode("/path%20with%20spaces"),
597 "/path with spaces"
598 );
599 assert_eq!(sftp_path_decode("/%E6%96%87%E4%BB%B6"), "/\u{6587}\u{4EF6}");
601 assert_eq!(sftp_path_decode("%2Froot%2Ftest"), "/root/test");
602 }
603
604 #[test]
605 fn test_build_ssh_options_with_password() {
606 let cmd = create_test_cmd();
607 let opts = cmd.build_ssh_options();
608 assert_eq!(opts.host, "example.com");
609 assert_eq!(opts.port, 2222);
610 assert_eq!(opts.username, "testuser");
611 assert_eq!(opts.password.as_deref(), Some("secretpass"));
612 assert_eq!(
613 opts.connect_timeout,
614 Duration::from_secs(constants::SFTP_CONNECT_TIMEOUT_SECS)
615 );
616 }
617
618 #[test]
619 fn test_build_ssh_options_without_password() {
620 let cmd = SftpDownloadCommand {
621 group: Arc::new(tokio::sync::RwLock::new(RequestGroup::new(
622 GroupId::new(99),
623 vec!["sftp://user@host/file".to_string()],
624 DownloadOptions::default(),
625 ))),
626 output_path: std::path::PathBuf::from("/tmp/out"),
627 started: false,
628 completed_bytes: 0,
629 host: "host".to_string(),
630 port: 22,
631 username: "user".to_string(),
632 password: None,
633 remote_path: "/file".to_string(),
634 };
635 let opts = cmd.build_ssh_options();
636 assert!(opts.password.is_none());
637 }
638
639 #[test]
640 fn test_map_ssh_auth_error_to_fatal() {
641 let err = SshError::AuthFailed {
642 method: "password".into(),
643 message: "bad pass".into(),
644 };
645 let mapped = SftpDownloadCommand::map_ssh_error(&err, "h", 22, "/f");
646 assert!(matches!(
647 mapped,
648 Aria2Error::Fatal(FatalError::PermissionDenied { .. })
649 ));
650 }
651
652 #[test]
653 fn test_map_ssh_connect_timeout_to_recoverable() {
654 let err = SshError::ConnectTimeout {
655 host: "h".into(),
656 port: 22,
657 timeout_secs: 15,
658 };
659 let mapped = SftpDownloadCommand::map_ssh_error(&err, "h", 22, "/f");
660 assert!(matches!(
661 mapped,
662 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure { .. })
663 ));
664 }
665
666 #[test]
667 fn test_map_file_not_found_to_fatal() {
668 let err = FileOpError::NotFound {
669 path: "/missing".to_string(),
670 };
671 let mapped = SftpDownloadCommand::map_file_op_error(&err, "host", "/missing");
672 assert!(matches!(
673 mapped,
674 Aria2Error::Fatal(FatalError::FileNotFound { .. })
675 ));
676 }
677
678 #[test]
679 fn test_map_permission_denied_to_fatal() {
680 let err = FileOpError::PermissionDenied {
681 path: "/secret".to_string(),
682 };
683 let mapped = SftpDownloadCommand::map_file_op_error(&err, "host", "/secret");
684 assert!(matches!(
685 mapped,
686 Aria2Error::Fatal(FatalError::PermissionDenied { .. })
687 ));
688 }
689
690 #[test]
691 fn test_map_network_error_to_recoverable() {
692 let err = FileOpError::Network {
693 operation: "READ".into(),
694 message: "Connection reset".into(),
695 };
696 let mapped = SftpDownloadCommand::map_file_op_error(&err, "host", "/f");
697 assert!(matches!(
698 mapped,
699 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure { .. })
700 ));
701 }
702
703 #[test]
704 fn test_constants() {
705 assert_eq!(constants::SFTP_CONNECT_TIMEOUT_SECS, 15);
706 assert_eq!(constants::SFTP_READ_TIMEOUT_SECS, 30);
707 assert_eq!(constants::SFTP_COMMAND_TIMEOUT_SECS, 300);
708 assert_eq!(constants::SFTP_DISK_WRITE_CHUNK_SIZE, 65536); assert_eq!(constants::SFTP_SPEED_UPDATE_INTERVAL_MS, 500);
710 }
711
712 fn create_test_cmd() -> SftpDownloadCommand {
714 SftpDownloadCommand {
715 group: Arc::new(tokio::sync::RwLock::new(RequestGroup::new(
716 GroupId::new(1),
717 vec!["sftp://testuser:secretpass@example.com:2222/path/to/file.zip".to_string()],
718 DownloadOptions::default(),
719 ))),
720 output_path: std::path::PathBuf::from("/tmp/download/file.zip"),
721 started: false,
722 completed_bytes: 0,
723 host: "example.com".to_string(),
724 port: 2222,
725 username: "testuser".to_string(),
726 password: Some("secretpass".to_string()),
727 remote_path: "/path/to/file.zip".to_string(),
728 }
729 }
730}