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#[derive(Debug, Clone)]
13pub struct FtpDownloadOptions {
14 pub buffer_size: usize,
15 pub resume_offset: Option<u64>,
16 pub max_retries: u32,
17 pub binary_mode: bool,
19 pub data_connect_timeout: Duration,
21 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
38fn 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#[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#[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 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 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
98pub struct FtpDownload<'a> {
100 conn: &'a mut FtpConnection,
101 options: FtpDownloadOptions,
102}
103
104impl<'a> FtpDownload<'a> {
105 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 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 if self.options.binary_mode {
127 self.conn.type_image().await?;
128 } else {
129 self.conn.type_ascii().await?;
130 }
131
132 let file_size = self.conn.size(remote_path).await.ok();
134
135 if let Some(offset) = self.options.resume_offset
137 && offset > 0
138 {
139 self.conn.rest(offset).await?;
140 }
141
142 let (data_host, data_port) = self.establish_data_connection().await?;
144
145 self.conn.retr(remote_path).await?;
147
148 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 pub async fn download_to_memory(&mut self, remote_path: &str) -> Result<Vec<u8>, String> {
164 self.conn.type_image().await?;
166
167 let file_size = self.conn.size(remote_path).await.ok();
169
170 if let Some(offset) = self.options.resume_offset
172 && offset > 0
173 {
174 self.conn.rest(offset).await?;
175 }
176
177 let (data_host, data_port) = self.establish_data_connection().await?;
179
180 self.conn.retr(remote_path).await?;
182
183 let data = self
185 .receive_data_to_memory(&data_host, data_port, file_size)
186 .await?;
187
188 Ok(data)
189 }
190
191 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 self.conn.cwd(remote_dir).await?;
206
207 let list_resp = self.conn.list(None).await?;
209
210 if list_resp.code != 150 && list_resp.code != 125 && list_resp.code != 226 {
211 }
214
215 let (data_host, data_port) = self.establish_data_connection().await?;
217
218 let listing_data = self
220 .receive_data_to_memory(&data_host, data_port, None)
221 .await?;
222
223 let listing_str = String::from_utf8_lossy(&listing_data);
225 let entries = parse_ftp_list_response(&listing_str);
226
227 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 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 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 async fn establish_data_connection(&mut self) -> Result<(String, u16), String> {
259 if self.conn.options.passive_mode {
260 match self.conn.epsv().await {
262 Ok(port) => {
263 Ok((self.conn.host.clone(), port))
265 }
266 Err(_) => {
267 self.conn.pasv().await
269 }
270 }
271 } else {
272 match self.conn.eprt_active().await {
274 Ok((host, port)) => Ok((host, port)),
275 Err(_) => {
276 let port = self.conn.port_active().await?;
278 Ok(("127.0.0.1".to_string(), port))
279 }
280 }
281 }
282 }
283
284 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 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 let mut file = tokio::fs::File::create(local_path)
309 .await
310 .map_err(|e| format!("Failed to create local file: {}", e))?;
311
312 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 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; if bytes_read == 0 {
336 break; }
338
339 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 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 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 file.flush()
383 .await
384 .map_err(|e| format!("Failed to flush file: {}", e))?;
385 drop(data_stream); 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 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 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 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 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 let _final_resp = self.conn.read_response().await.ok(); 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()); let failed = DownloadResult {
505 file_path: "test.bin".into(),
506 bytes_downloaded: 1000,
507 total_size: Some(1000),
508 success: false, 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 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 let not_found = std::io::Error::new(ErrorKind::NotFound, "not found");
545 assert!(!is_transient_io_error(¬_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 let temp_error = std::io::Error::other("temporary failure");
553 assert!(is_transient_io_error(&temp_error));
554 }
555}