Skip to main content

aria2_protocol/ftp/
download.rs

1use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, SeekFrom};
2use tokio::net::TcpStream;
3use tokio::time::{Duration, timeout};
4use tracing::warn;
5
6use super::connection::{FtpConnection, FtpResponseClass};
7use super::listing::parse_ftp_list_response;
8
9const DEFAULT_BUFFER_SIZE: usize = 65536;
10
11/// FTP download configuration options
12#[derive(Debug, Clone)]
13pub struct FtpDownloadOptions {
14    pub buffer_size: usize,
15    pub resume_offset: Option<u64>,
16    pub max_retries: u32,
17    /// Transfer mode (binary or ASCII)
18    pub binary_mode: bool,
19    /// Timeout for data connection establishment
20    pub data_connect_timeout: Duration,
21    /// Whether to download directories recursively
22    pub recursive_download: bool,
23}
24
25impl Default for FtpDownloadOptions {
26    fn default() -> Self {
27        Self {
28            buffer_size: DEFAULT_BUFFER_SIZE,
29            resume_offset: None,
30            max_retries: 3,
31            binary_mode: true,
32            data_connect_timeout: Duration::from_secs(30),
33            recursive_download: false,
34        }
35    }
36}
37
38/// Check if an IO error is transient and retry-worthy
39fn is_transient_io_error(e: &std::io::Error) -> bool {
40    use std::io::ErrorKind;
41    matches!(
42        e.kind(),
43        ErrorKind::Interrupted
44            | ErrorKind::WouldBlock
45            | ErrorKind::ConnectionReset
46            | ErrorKind::ConnectionAborted
47            | ErrorKind::BrokenPipe
48            | ErrorKind::TimedOut
49    ) || e.to_string().to_lowercase().contains("temporary")
50}
51
52/// Download progress information
53#[derive(Debug, Clone)]
54pub struct DownloadProgress {
55    pub downloaded_bytes: u64,
56    pub total_bytes: Option<u64>,
57    pub speed_bytes_per_sec: f64,
58}
59
60/// Result of a file download operation
61#[derive(Debug, Clone)]
62pub struct DownloadResult {
63    pub file_path: String,
64    pub bytes_downloaded: u64,
65    pub total_size: Option<u64>,
66    pub success: bool,
67    pub average_speed_bps: f64,
68    pub duration_secs: f64,
69}
70
71impl DownloadResult {
72    /// Check if the download completed successfully with all bytes received
73    pub fn is_complete(&self) -> bool {
74        self.success
75            && match self.total_size {
76                Some(total) => self.bytes_downloaded >= total,
77                None => self.bytes_downloaded > 0,
78            }
79    }
80
81    /// Convert byte count to human-readable string
82    pub fn human_readable_size(bytes: u64) -> String {
83        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
84        let mut size = bytes as f64;
85        let mut unit_idx = 0;
86        while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
87            size /= 1024.0;
88            unit_idx += 1;
89        }
90        if unit_idx == 0 {
91            format!("{} {}", bytes, UNITS[unit_idx])
92        } else {
93            format!("{:.2} {}", size, UNITS[unit_idx])
94        }
95    }
96}
97
98/// FTP download manager that handles file transfers
99pub struct FtpDownload<'a> {
100    conn: &'a mut FtpConnection,
101    options: FtpDownloadOptions,
102}
103
104impl<'a> FtpDownload<'a> {
105    /// Create a new FTP download manager
106    pub fn new(conn: &'a mut FtpConnection, options: Option<FtpDownloadOptions>) -> Self {
107        Self {
108            conn,
109            options: options.unwrap_or_default(),
110        }
111    }
112
113    /// Download a single file from FTP server to local filesystem
114    ///
115    /// # Arguments
116    /// * `remote_path` - Path to the file on the FTP server
117    /// * `local_path` - Local path where the file should be saved
118    /// * `progress_callback` - Optional callback for progress updates
119    pub async fn download_file(
120        &mut self,
121        remote_path: &str,
122        local_path: &str,
123        progress_callback: Option<fn(DownloadProgress)>,
124    ) -> Result<DownloadResult, String> {
125        // Set transfer mode (binary by default)
126        if self.options.binary_mode {
127            self.conn.type_image().await?;
128        } else {
129            self.conn.type_ascii().await?;
130        }
131
132        // Probe file size before download
133        let file_size = self.conn.size(remote_path).await.ok();
134
135        // Set resume offset if resuming
136        if let Some(offset) = self.options.resume_offset
137            && offset > 0
138        {
139            self.conn.rest(offset).await?;
140        }
141
142        // Establish data connection (try passive mode first)
143        let (data_host, data_port) = self.establish_data_connection().await?;
144
145        // Send RETR command to initiate transfer
146        self.conn.retr(remote_path).await?;
147
148        // Connect to data port and receive file content
149        let result = self
150            .receive_data_to_file(
151                &data_host,
152                data_port,
153                local_path,
154                file_size,
155                progress_callback,
156            )
157            .await?;
158
159        Ok(result)
160    }
161
162    /// Download a file into memory (for small files or when disk I/O is not needed)
163    pub async fn download_to_memory(&mut self, remote_path: &str) -> Result<Vec<u8>, String> {
164        // Set binary mode
165        self.conn.type_image().await?;
166
167        // Get file size for pre-allocation
168        let file_size = self.conn.size(remote_path).await.ok();
169
170        // Set resume offset if specified
171        if let Some(offset) = self.options.resume_offset
172            && offset > 0
173        {
174            self.conn.rest(offset).await?;
175        }
176
177        // Establish data connection
178        let (data_host, data_port) = self.establish_data_connection().await?;
179
180        // Initiate RETR command
181        self.conn.retr(remote_path).await?;
182
183        // Receive data into memory
184        let data = self
185            .receive_data_to_memory(&data_host, data_port, file_size)
186            .await?;
187
188        Ok(data)
189    }
190
191    /// Download a directory recursively (if recursive_download is enabled)
192    ///
193    /// Returns results for each file downloaded
194    pub async fn download_directory(
195        &mut self,
196        remote_dir: &str,
197        local_base_dir: &str,
198        progress_callback: Option<fn(DownloadProgress)>,
199    ) -> Result<Vec<DownloadResult>, String> {
200        if !self.options.recursive_download {
201            return Err("Recursive download not enabled in options".to_string());
202        }
203
204        // Change to remote directory
205        self.conn.cwd(remote_dir).await?;
206
207        // Request directory listing
208        let list_resp = self.conn.list(None).await?;
209
210        if list_resp.code != 150 && list_resp.code != 125 && list_resp.code != 226 {
211            // If LIST didn't open data connection, we need to handle it differently
212            // For now, assume listing will come through data channel
213        }
214
215        // Establish data connection for LIST response
216        let (data_host, data_port) = self.establish_data_connection().await?;
217
218        // Read directory listing from data connection
219        let listing_data = self
220            .receive_data_to_memory(&data_host, data_port, None)
221            .await?;
222
223        // Parse listing
224        let listing_str = String::from_utf8_lossy(&listing_data);
225        let entries = parse_ftp_list_response(&listing_str);
226
227        // Create local directory
228        std::fs::create_dir_all(local_base_dir)
229            .map_err(|e| format!("Failed to create local directory: {}", e))?;
230
231        let mut results = Vec::new();
232        for entry in entries {
233            if entry.is_directory {
234                // Recursively download subdirectory
235                let sub_remote = format!("{}/{}", remote_dir.trim_end_matches('/'), entry.name);
236                let sub_local = format!("{}/{}", local_base_dir.trim_end_matches('/'), entry.name);
237
238                let sub_results =
239                    Box::pin(self.download_directory(&sub_remote, &sub_local, progress_callback))
240                        .await?;
241                results.extend(sub_results);
242            } else {
243                // Download individual file
244                let remote_file = format!("{}/{}", remote_dir.trim_end_matches('/'), entry.name);
245                let local_file = format!("{}/{}", local_base_dir.trim_end_matches('/'), entry.name);
246
247                let result = self
248                    .download_file(&remote_file, &local_file, progress_callback)
249                    .await?;
250                results.push(result);
251            }
252        }
253
254        Ok(results)
255    }
256
257    /// Establish data connection using configured mode (passive/active)
258    async fn establish_data_connection(&mut self) -> Result<(String, u16), String> {
259        if self.conn.options.passive_mode {
260            // Try EPSV first (supports IPv6), fallback to PASV
261            match self.conn.epsv().await {
262                Ok(port) => {
263                    // For EPSV, use same host as control connection
264                    Ok((self.conn.host.clone(), port))
265                }
266                Err(_) => {
267                    // Fallback to PASV
268                    self.conn.pasv().await
269                }
270            }
271        } else {
272            // Active mode
273            match self.conn.eprt_active().await {
274                Ok((host, port)) => Ok((host, port)),
275                Err(_) => {
276                    // Try PORT for IPv4
277                    let port = self.conn.port_active().await?;
278                    Ok(("127.0.0.1".to_string(), port))
279                }
280            }
281        }
282    }
283
284    /// Receive data stream and write to file with error handling and retries
285    async fn receive_data_to_file(
286        &mut self,
287        data_host: &str,
288        data_port: u16,
289        local_path: &str,
290        file_size: Option<u64>,
291        progress_callback: Option<fn(DownloadProgress)>,
292    ) -> Result<DownloadResult, String> {
293        // Connect to data port with timeout
294        let mut data_stream: TcpStream = timeout(
295            self.options.data_connect_timeout,
296            TcpStream::connect((data_host, data_port)),
297        )
298        .await
299        .map_err(|_| {
300            format!(
301                "FTP data connection timeout ({}s)",
302                self.options.data_connect_timeout.as_secs()
303            )
304        })?
305        .map_err(|e| format!("FTP data connection failed: {}", e))?;
306
307        // Open/create local file
308        let mut file = tokio::fs::File::create(local_path)
309            .await
310            .map_err(|e| format!("Failed to create local file: {}", e))?;
311
312        // Seek to resume offset if resuming
313        if let Some(offset) = self.options.resume_offset
314            && offset > 0
315        {
316            file.seek(SeekFrom::Start(offset))
317                .await
318                .map_err(|e| format!("Failed to seek file: {}", e))?;
319        }
320
321        // Data receive loop with retry logic
322        let mut buffer = vec![0u8; self.options.buffer_size];
323        let mut total_downloaded = self.options.resume_offset.unwrap_or(0);
324        let start_time = std::time::Instant::now();
325        let mut read_retry_count = 0u32;
326        const MAX_READ_RETRIES: u32 = 3;
327
328        loop {
329            let read_result = data_stream.read(&mut buffer).await;
330
331            match read_result {
332                Ok(bytes_read) => {
333                    read_retry_count = 0; // Reset retry counter on success
334
335                    if bytes_read == 0 {
336                        break; // End of stream
337                    }
338
339                    // Write to local file
340                    file.write_all(&buffer[..bytes_read])
341                        .await
342                        .map_err(|e| format!("Failed to write to local file: {}", e))?;
343
344                    total_downloaded += bytes_read as u64;
345
346                    // Report progress
347                    if let Some(cb) = progress_callback {
348                        let elapsed = start_time.elapsed().as_secs_f64();
349                        let speed = if elapsed > 0.0 {
350                            total_downloaded as f64 / elapsed
351                        } else {
352                            0.0
353                        };
354                        cb(DownloadProgress {
355                            downloaded_bytes: total_downloaded,
356                            total_bytes: file_size,
357                            speed_bytes_per_sec: speed,
358                        });
359                    }
360                }
361                Err(ref e) if is_transient_io_error(e) && read_retry_count < MAX_READ_RETRIES => {
362                    // Transient error - retry with exponential backoff
363                    read_retry_count += 1;
364                    let wait_ms = 1000u64 * (1 << (read_retry_count - 1));
365                    warn!(
366                        "FTP read error (#{}), retrying in {}ms...",
367                        read_retry_count, wait_ms
368                    );
369                    tokio::time::sleep(Duration::from_millis(wait_ms)).await;
370                    continue;
371                }
372                Err(e) => {
373                    return Err(format!(
374                        "FTP data read failed after {} retries: {}",
375                        read_retry_count, e
376                    ));
377                }
378            }
379        }
380
381        // Flush and close file
382        file.flush()
383            .await
384            .map_err(|e| format!("Failed to flush file: {}", e))?;
385        drop(data_stream); // Close data connection
386
387        // Read final response from control channel
388        let final_resp = self.conn.read_response().await?;
389        if final_resp.class() != FtpResponseClass::PositiveCompletion
390            && final_resp.class() != FtpResponseClass::PositivePreliminary
391        {
392            return Err(format!(
393                "Download completed but server reported error: {} {}",
394                final_resp.code, final_resp.message
395            ));
396        }
397
398        // Calculate statistics
399        let elapsed = start_time.elapsed().as_secs_f64();
400        let avg_speed = if elapsed > 0.0 {
401            total_downloaded as f64 / elapsed
402        } else {
403            0.0
404        };
405
406        Ok(DownloadResult {
407            file_path: local_path.to_string(),
408            bytes_downloaded: total_downloaded,
409            total_size: file_size,
410            success: true,
411            average_speed_bps: avg_speed,
412            duration_secs: elapsed,
413        })
414    }
415
416    /// Receive data stream into memory buffer
417    async fn receive_data_to_memory(
418        &mut self,
419        data_host: &str,
420        data_port: u16,
421        expected_size: Option<u64>,
422    ) -> Result<Vec<u8>, String> {
423        // Connect to data port
424        let mut data_stream = timeout(
425            self.options.data_connect_timeout,
426            TcpStream::connect((data_host, data_port)),
427        )
428        .await
429        .map_err(|_| "FTP data connection timeout")?
430        .map_err(|e| format!("FTP data connection failed: {}", e))?;
431
432        // Pre-allocate buffer based on expected size
433        let capacity = expected_size.unwrap_or(1024 * 1024) as usize;
434        let mut result = Vec::with_capacity(capacity);
435        let mut buffer = vec![0u8; self.options.buffer_size];
436
437        loop {
438            let bytes_read = data_stream
439                .read(&mut buffer)
440                .await
441                .map_err(|e| format!("FTP data read error: {}", e))?;
442
443            if bytes_read == 0 {
444                break;
445            }
446
447            result.extend_from_slice(&buffer[..bytes_read]);
448        }
449
450        drop(data_stream);
451
452        // Read final response (may timeout for some servers)
453        let _final_resp = self.conn.read_response().await.ok(); // Ignore errors
454
455        Ok(result)
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_human_readable_size() {
465        assert_eq!(DownloadResult::human_readable_size(500), "500 B");
466        assert_eq!(DownloadResult::human_readable_size(1024), "1.00 KB");
467        assert_eq!(DownloadResult::human_readable_size(1536), "1.50 KB");
468        assert_eq!(DownloadResult::human_readable_size(1048576), "1.00 MB");
469        assert_eq!(DownloadResult::human_readable_size(1073741824), "1.00 GB");
470    }
471
472    #[test]
473    fn test_download_result_complete() {
474        let full = DownloadResult {
475            file_path: "test.bin".into(),
476            bytes_downloaded: 1000,
477            total_size: Some(1000),
478            success: true,
479            average_speed_bps: 1000.0,
480            duration_secs: 1.0,
481        };
482        assert!(full.is_complete());
483
484        let partial = DownloadResult {
485            file_path: "test.bin".into(),
486            bytes_downloaded: 500,
487            total_size: Some(1000),
488            success: true,
489            average_speed_bps: 500.0,
490            duration_secs: 1.0,
491        };
492        assert!(!partial.is_complete());
493
494        let unknown_total = DownloadResult {
495            file_path: "test.bin".into(),
496            bytes_downloaded: 100,
497            total_size: None,
498            success: true,
499            average_speed_bps: 100.0,
500            duration_secs: 1.0,
501        };
502        assert!(unknown_total.is_complete()); // Any data with unknown total is complete
503
504        let failed = DownloadResult {
505            file_path: "test.bin".into(),
506            bytes_downloaded: 1000,
507            total_size: Some(1000),
508            success: false, // Failed
509            average_speed_bps: 1000.0,
510            duration_secs: 1.0,
511        };
512        assert!(!failed.is_complete());
513    }
514
515    #[test]
516    fn test_ftp_download_options_default() {
517        let opts = FtpDownloadOptions::default();
518        assert_eq!(opts.buffer_size, DEFAULT_BUFFER_SIZE);
519        assert!(opts.resume_offset.is_none());
520        assert_eq!(opts.max_retries, 3);
521        assert!(opts.binary_mode);
522        assert_eq!(opts.data_connect_timeout, Duration::from_secs(30));
523        assert!(!opts.recursive_download);
524    }
525
526    #[test]
527    fn test_is_transient_io_error() {
528        use std::io::ErrorKind;
529
530        // Transient errors (should retry)
531        let interrupted = std::io::Error::new(ErrorKind::Interrupted, "interrupted");
532        assert!(is_transient_io_error(&interrupted));
533
534        let would_block = std::io::Error::new(ErrorKind::WouldBlock, "would block");
535        assert!(is_transient_io_error(&would_block));
536
537        let connection_reset = std::io::Error::new(ErrorKind::ConnectionReset, "connection reset");
538        assert!(is_transient_io_error(&connection_reset));
539
540        let timed_out = std::io::Error::new(ErrorKind::TimedOut, "timed out");
541        assert!(is_transient_io_error(&timed_out));
542
543        // Non-transient errors (should not retry)
544        let not_found = std::io::Error::new(ErrorKind::NotFound, "not found");
545        assert!(!is_transient_io_error(&not_found));
546
547        let permission_denied =
548            std::io::Error::new(ErrorKind::PermissionDenied, "permission denied");
549        assert!(!is_transient_io_error(&permission_denied));
550
551        // Error with "temporary" in message
552        let temp_error = std::io::Error::other("temporary failure");
553        assert!(is_transient_io_error(&temp_error));
554    }
555}