Skip to main content

aria2_core/engine/
sftp_download_command.rs

1//! SFTP Download Command
2//!
3//! Implements the complete execution logic for SFTP downloads within the aria2
4//! download engine. This command integrates the SFTP protocol layer with the
5//! engine's RequestGroup, DiskWriter, and rate limiting infrastructure.
6//!
7//! ## Execution Flow
8//!
9//! ```text
10//! execute()
11//!   -> 1. Parse/validate URI and options
12//!   -> 2. Establish SSH connection (with auth retry)
13//!   -> 3. Initialize SFTP subsystem (version negotiation)
14//!   -> 4. STAT remote file (get size, verify existence)
15//!   -> 5. Prepare local output (create dir, open disk writer)
16//!   -> 6. Chunked read loop:
17//!        READ(remote_handle, offset, buf) -> WRITE(disk_writer, chunk) -> UPDATE_PROGRESS
18//!   -> 7. Cleanup (close handles, disconnect)
19//!   -> 8. Mark RequestGroup as complete
20//! ```
21
22use 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
35// Re-export protocol layer types for use in this module
36use aria2_protocol::sftp::connection::{HostKeyCheckingMode, SshConnection, SshError, SshOptions};
37use aria2_protocol::sftp::file_ops::{FileOpError, OpenFlags};
38use aria2_protocol::sftp::session::SftpSession;
39
40// =============================================================================
41// SFTP Download Command
42// =============================================================================
43
44/// Command that executes an SFTP file download from a remote server to local disk.
45///
46/// This is the primary integration point between the aria2 download engine and
47/// the SFTP protocol layer. It manages the full lifecycle of an SFTP download
48/// including connection management, authentication, data transfer, progress tracking,
49/// and cleanup.
50pub struct SftpDownloadCommand {
51    /// The request group that owns this download (tracks state, progress, etc.)
52    group: Arc<tokio::sync::RwLock<RequestGroup>>,
53    /// Local filesystem path where the downloaded file will be written
54    output_path: std::path::PathBuf,
55    /// Whether the command has started executing (prevents double-start)
56    started: bool,
57    /// Total bytes completed so far (for progress tracking)
58    completed_bytes: u64,
59    /// Remote server hostname or IP
60    host: String,
61    /// Remote server port (typically 22)
62    port: u16,
63    /// Username for SSH authentication
64    username: String,
65    /// Password for authentication (optional if using key-based auth)
66    password: Option<String>,
67    /// Path to the file on the remote SFTP server
68    remote_path: String,
69}
70
71impl SftpDownloadCommand {
72    /// Create a new SFTP download command.
73    ///
74    /// # Arguments
75    /// * `gid` - Unique group identifier for this download
76    /// * `uri` - The sftp:// URI to download from
77    /// * `options` - Download configuration options
78    /// * `output_dir` - Optional override for output directory
79    /// * `output_name` - Optional override for output filename
80    ///
81    /// # URI Format
82    /// ```text
83    /// sftp://[user[:password]@]host[:port]/path/to/file
84    ///
85    /// Examples:
86    ///   sftp://user@example.com/path/to/file.txt
87    ///   sftp://admin:secret@192.168.1.100:2222/data/archive.tar.gz
88    ///   sftp://root@server.example.com:22/etc/config.conf
89    /// ```
90    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        // Step 1: Parse the SFTP URI into components
98        let (host, port, username, password, remote_path) = Self::parse_uri(uri)?;
99
100        // Step 2: Determine output directory
101        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        // Step 3: Determine output filename
107        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        // Step 4: Build full output path
113        let path = std::path::PathBuf::from(&dir).join(&filename);
114
115        // Step 5: Create the request group
116        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    /// Parse an sftp:// URI into its component parts.
142    ///
143    /// Supports the following formats:
144    /// - `sftp://user@host/path`
145    /// - `sftp://user:password@host/path`
146    /// - `sftp://user@host:port/path`
147    /// - `sftp://user:password@host:port/path`
148    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        // Split authority from path
158        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        // Split user info from host:port
164        let (username, rest) = match auth_host_port.find('@') {
165            Some(idx) => (&auth_host_port[..idx], &auth_host_port[idx + 1..]),
166            None => {
167                // No username in URI; try environment variable
168                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        // Extract optional password from username
180        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        // Split host from port
184        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    /// Extract the filename component from a remote path.
198    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    /// Get a read-only reference to the request group.
207    pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
208        self.group.read().await
209    }
210
211    /// Build SshOptions from the command's stored credentials.
212    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    /// Map an SshError to the appropriate Aria2Error for engine-level handling.
229    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    /// Map a FileOpError to the appropriate Aria2Error for engine-level handling.
255    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
277/// Decode URL-encoded characters in an SFTP path (%XX sequences).
278/// Decode a percent-encoded SFTP path, handling UTF-8 multi-byte sequences correctly.
279/// For example, "%E6%96%87%E4%BB%B6" should decode to the Chinese characters for "file".
280fn 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                // Invalid percent-encoding, push literal characters
290                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    // Decode the full byte sequence as UTF-8, with lossy fallback for invalid sequences
298    String::from_utf8_lossy(&bytes).into_owned()
299}
300
301#[async_trait]
302impl Command for SftpDownloadCommand {
303    /// Execute the SFTP download.
304    ///
305    /// This is the main entry point called by the download engine. It orchestrates
306    /// the entire download lifecycle from connection establishment through data
307    /// transfer to cleanup.
308    async fn execute(&mut self) -> Result<()> {
309        // -----------------------------------------------------------------
310        // Phase 0: Initialization
311        // -----------------------------------------------------------------
312        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        // Ensure output directory exists
326        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        // -----------------------------------------------------------------
339        // Phase 1: SSH Connection
340        // -----------------------------------------------------------------
341        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        // -----------------------------------------------------------------
362        // Phase 2: SFTP Session Initialization
363        // -----------------------------------------------------------------
364        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                // Attempt graceful disconnect before returning error
370                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        // -----------------------------------------------------------------
385        // Phase 3: Stat Remote File
386        // -----------------------------------------------------------------
387        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        // Update RequestGroup with total length
413        {
414            let mut g = self.group.write().await;
415            g.set_total_length(total_length).await;
416        }
417
418        // -----------------------------------------------------------------
419        // Phase 4: Open Remote File for Reading
420        // -----------------------------------------------------------------
421        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        // -----------------------------------------------------------------
431        // Phase 5: Prepare Local Disk Writer
432        // -----------------------------------------------------------------
433        let raw_writer = DefaultDiskWriter::new(&self.output_path);
434
435        // Apply rate limiting if configured
436        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        // -----------------------------------------------------------------
449        // Phase 6: Main Download Loop (Chunked Read + Write)
450        // -----------------------------------------------------------------
451        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; // Download complete
462            }
463
464            // Calculate how much to read this iteration
465            let to_read = (constants::SFTP_DISK_WRITE_CHUNK_SIZE as u64).min(remaining) as usize;
466
467            // Read chunk from remote file at current offset
468            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            // Write chunk to local disk via disk writer
493            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            // Update progress in RequestGroup
506            {
507                let g = self.group.write().await;
508                g.update_progress(self.completed_bytes).await;
509
510                // Periodic speed calculation (every ~500ms)
511                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        // -----------------------------------------------------------------
523        // Phase 7: Finalize and Cleanup
524        // -----------------------------------------------------------------
525
526        // Close remote file handle
527        if let Err(e) = remote_file.close().await {
528            warn!("[SFTP-CMD] Warning closing remote file: {}", e);
529        }
530
531        // Finalize disk writer (flush, sync, etc.)
532        if let Err(e) = writer.finalize().await {
533            warn!("[SFTP-CMD] Warning finalizing disk writer: {}", e);
534        }
535
536        // Disconnect SSH session
537        if let Err(e) = conn.disconnect().await {
538            warn!("[SFTP-CMD] Warning during SSH disconnect: {}", e);
539        }
540
541        // Calculate final statistics
542        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        // Mark RequestGroup as complete
552        {
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    /// Return the current status of this command.
570    fn status(&self) -> CommandStatus {
571        if self.completed_bytes > 0 {
572            CommandStatus::Running
573        } else {
574            CommandStatus::Pending
575        }
576    }
577
578    /// Return the timeout for this command.
579    fn timeout(&self) -> Option<Duration> {
580        Some(Duration::from_secs(constants::SFTP_COMMAND_TIMEOUT_SECS))
581    }
582}
583
584// =============================================================================
585// Unit Tests
586// =============================================================================
587
588#[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        // UTF-8 encoded Chinese path: "%E6%96%87%E4%BB%B6" decodes to "文件"
600        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); // 64KB
709        assert_eq!(constants::SFTP_SPEED_UPDATE_INTERVAL_MS, 500);
710    }
711
712    /// Helper to create a test command instance
713    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}