1use async_trait::async_trait;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
5use tracing::{debug, error, info, warn};
6
7use crate::constants;
8use crate::engine::command::{Command, CommandStatus};
9use crate::error::{Aria2Error, FatalError, RecoverableError, Result};
10use crate::filesystem::disk_writer::{DefaultDiskWriter, DiskWriter};
11use crate::rate_limiter::{RateLimiter, RateLimiterConfig, ThrottledWriter};
12use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
13
14pub struct FtpDownloadCommand {
16 group: Arc<tokio::sync::RwLock<RequestGroup>>,
17 output_path: std::path::PathBuf,
18 started: bool,
19 completed_bytes: u64,
20 host: String,
21 port: u16,
22 remote_path: String,
23 username: String,
24 password: String,
25 resume_offset: u64,
27 passive_mode: bool,
29 max_retries: u32,
31 current_retry: u32,
33}
34
35impl FtpDownloadCommand {
36 pub fn new(
38 gid: GroupId,
39 uri: &str,
40 options: &DownloadOptions,
41 output_dir: Option<&str>,
42 output_name: Option<&str>,
43 ) -> Result<Self> {
44 let (host, port, username, password, remote_path) = Self::parse_uri(uri)?;
45
46 let dir = output_dir
47 .map(|d| d.to_string())
48 .or_else(|| options.dir.clone())
49 .unwrap_or_else(|| constants::DEFAULT_OUTPUT_DIR.to_string());
50
51 let filename = output_name
52 .map(|n| n.to_string())
53 .or_else(|| Self::extract_filename(&remote_path))
54 .unwrap_or_else(|| constants::DEFAULT_FILENAME.to_string());
55
56 let path = std::path::PathBuf::from(&dir).join(&filename);
57
58 let resume_offset = if path.exists() {
60 std::fs::metadata(&path).ok().map(|m| m.len()).unwrap_or(0)
61 } else {
62 0
63 };
64
65 let group = RequestGroup::new(gid, vec![uri.to_string()], options.clone());
66 info!(
67 "FtpDownloadCommand created: {} -> {} ({}:{}/{}) [resume_offset={}]",
68 uri,
69 path.display(),
70 host,
71 port,
72 remote_path,
73 resume_offset
74 );
75
76 Ok(Self {
77 group: Arc::new(tokio::sync::RwLock::new(group)),
78 output_path: path,
79 started: false,
80 completed_bytes: 0,
81 host,
82 port,
83 remote_path,
84 username,
85 password,
86 resume_offset,
87 passive_mode: true, max_retries: constants::DEFAULT_MAX_RETRIES,
89 current_retry: 0,
90 })
91 }
92
93 fn parse_uri(uri: &str) -> Result<(String, u16, String, String, String)> {
95 if !uri.starts_with("ftp://") && !uri.starts_with("ftps://") {
96 return Err(Aria2Error::Fatal(FatalError::UnsupportedProtocol {
97 protocol: "ftp".into(),
98 }));
99 }
100
101 let without_scheme = uri
102 .trim_start_matches("ftp://")
103 .trim_start_matches("ftps://");
104
105 let (auth_host_port, path) = match without_scheme.find('/') {
106 Some(idx) => (&without_scheme[..idx], &without_scheme[idx..]),
107 None => (without_scheme, "/"),
108 };
109
110 let (auth, host_port) = match auth_host_port.rfind('@') {
111 Some(idx) => (&auth_host_port[..idx], &auth_host_port[idx + 1..]),
112 None => ("", auth_host_port),
113 };
114
115 let (username, password) = if auth.is_empty() {
116 (
117 constants::FTP_DEFAULT_USER.to_string(),
118 constants::FTP_DEFAULT_PASSWORD.to_string(),
119 )
120 } else if let Some(colon_pos) = auth.find(':') {
121 (
122 auth[..colon_pos].to_string(),
123 auth[colon_pos + 1..].to_string(),
124 )
125 } else {
126 (auth.to_string(), String::new())
127 };
128
129 let (host, port) = match host_port.rfind(':') {
130 Some(idx) => (
131 host_port[..idx].to_string(),
132 host_port[idx + 1..]
133 .parse::<u16>()
134 .unwrap_or(constants::FTP_DEFAULT_PORT),
135 ),
136 None => (host_port.to_string(), constants::FTP_DEFAULT_PORT),
137 };
138
139 Ok((host, port, username, password, urlencoding_decode(path)))
140 }
141
142 fn extract_filename(remote_path: &str) -> Option<String> {
144 remote_path
145 .rsplit('/')
146 .next()
147 .filter(|s| !s.is_empty() && *s != "/")
148 .map(|s| s.to_string())
149 }
150
151 pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
153 self.group.read().await
154 }
155
156 #[allow(dead_code)] fn classify_ftp_error(&self, code: u16, message: &str) -> Aria2Error {
159 match code {
160 100..=399 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
162 message: format!("Unexpected positive response: {} {}", code, message),
163 }),
164 421 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
166 message: format!("Service not available: {}", message),
167 }),
168 425 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
169 message: format!("Can't open data connection: {}", message),
170 }),
171 426 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
172 message: format!("Connection closed; transfer aborted: {}", message),
173 }),
174 450 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
175 message: format!("Requested file action not taken: {}", message),
176 }),
177 451 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
178 message: format!("Requested action aborted: {}", message),
179 }),
180 452 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
181 message: format!("Requested action not taken: {}", message),
182 }),
183 500..=504 => Aria2Error::Fatal(FatalError::Config(format!(
185 "FTP syntax error: {} {}",
186 code, message
187 ))),
188 530 => Aria2Error::Fatal(FatalError::PermissionDenied {
189 path: format!("{}:{}", self.host, self.port),
190 }),
191 532 => Aria2Error::Fatal(FatalError::PermissionDenied {
192 path: "Account required for storing file".into(),
193 }),
194 550 => Aria2Error::Fatal(FatalError::FileNotFound {
195 path: self.remote_path.clone(),
196 }),
197 551 => Aria2Error::Fatal(FatalError::Config(format!(
198 "Page type unknown: {}",
199 message
200 ))),
201 552 => Aria2Error::Fatal(FatalError::Config(format!(
202 "Exceeded storage allocation: {}",
203 message
204 ))),
205 553 => Aria2Error::Fatal(FatalError::PermissionDenied {
206 path: format!("Filename not allowed: {}", message),
207 }),
208 _ => {
210 let msg_lower = message.to_lowercase();
212 if msg_lower.contains("not found")
213 || msg_lower.contains("no such")
214 || msg_lower.contains("access denied")
215 || msg_lower.contains("permission")
216 {
217 Aria2Error::Fatal(FatalError::FileNotFound {
218 path: self.remote_path.clone(),
219 })
220 } else if msg_lower.contains("login") || msg_lower.contains("auth") {
221 Aria2Error::Fatal(FatalError::PermissionDenied {
222 path: format!("{}:{}", self.host, self.port),
223 })
224 } else {
225 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
227 message: format!("FTP error {} {}: {}", code, message, self.remote_path),
228 })
229 }
230 }
231 }
232 }
233}
234
235fn urlencoding_decode(s: &str) -> String {
237 let mut result = String::with_capacity(s.len());
238 let mut chars = s.chars().peekable();
239 while let Some(c) = chars.next() {
240 if c == '%' {
241 let hex: String = chars.by_ref().take(2).collect();
242 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
243 result.push(byte as char);
244 } else {
245 result.push(c);
246 result.push_str(&hex);
247 }
248 } else {
249 result.push(c);
250 }
251 }
252 result
253}
254
255struct RawFtpControl {
257 reader: BufReader<tokio::net::TcpStream>,
258 host: String,
259}
260
261impl RawFtpControl {
262 async fn connect(host: &str, port: u16) -> Result<Self> {
264 let addr = format!("{}:{}", host, port);
265 let socket_addr: std::net::SocketAddr = addr.parse().map_err(|_| {
266 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
267 message: format!("cannot parse address: {}", addr),
268 })
269 })?;
270
271 debug!("Connecting to FTP server at {}:{}", host, port);
272
273 let stream = tokio::net::TcpStream::connect(socket_addr)
274 .await
275 .map_err(|e| {
276 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
277 message: format!("FTP connect failed to {}:{}: {}", host, port, e),
278 })
279 })?;
280
281 stream.set_nodelay(true).map_err(|e| {
283 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
284 message: format!("set_nodelay failed: {}", e),
285 })
286 })?;
287
288 let mut ctrl = Self {
289 reader: BufReader::new(stream),
290 host: host.to_string(),
291 };
292 let welcome = ctrl
293 .read_response(Duration::from_secs(constants::FTP_WELCOME_TIMEOUT_SECS))
294 .await?;
295
296 if !(200..300).contains(&welcome.0) && !(100..200).contains(&welcome.0) {
297 return Err(Aria2Error::Fatal(FatalError::Config(format!(
298 "FTP server rejected connection: {} {}",
299 welcome.0, welcome.1
300 ))));
301 }
302
303 info!("Connected to FTP server {}:{}", host, port);
304 Ok(ctrl)
305 }
306
307 async fn send_command(&mut self, cmd: &str) -> Result<()> {
309 debug!("FTP CMD: {}", cmd.trim());
310 self.reader
311 .get_mut()
312 .write_all(format!("{}\r\n", cmd).as_bytes())
313 .await
314 .map_err(|e| {
315 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
316 message: format!("FTP write command failed: {}", e),
317 })
318 })?;
319 self.reader.get_mut().flush().await.map_err(|e| {
320 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
321 message: format!("FTP flush failed: {}", e),
322 })
323 })?;
324 Ok(())
325 }
326
327 async fn read_response(&mut self, timeout_dur: Duration) -> Result<(u16, String)> {
329 let mut line = String::new();
330 let mut code: Option<u16> = None;
331 let mut message = String::new();
332 let mut is_multiline = false;
333
334 loop {
335 line.clear();
336 let bytes_read = tokio::time::timeout(timeout_dur, self.reader.read_line(&mut line))
337 .await
338 .map_err(|_| {
339 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
340 message: format!("FTP response timeout after {:?}", timeout_dur),
341 })
342 })?
343 .map_err(|e| {
344 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
345 message: format!("FTP read response error: {}", e),
346 })
347 })?;
348
349 if bytes_read == 0 {
350 break;
351 }
352 let trimmed = line.trim_end();
353 if trimmed.len() < 4 {
354 continue;
355 }
356
357 let response_code: u16 = trimmed[..3].parse().unwrap_or(0);
358 if code.is_none() {
359 code = Some(response_code);
360 }
361
362 let sep = trimmed.as_bytes()[3];
363 if sep == b'-' && !is_multiline {
364 is_multiline = true;
365 if trimmed.len() > 4 {
366 message.push_str(&trimmed[4..]);
367 }
368 message.push('\n');
369 continue;
370 }
371 if is_multiline {
372 if trimmed.starts_with(&format!("{} ", code.unwrap_or(0))) {
373 if trimmed.len() > 4 {
374 message.push_str(&trimmed[4..]);
375 }
376 break;
377 }
378 if trimmed.len() > 4 {
379 message.push_str(&trimmed[4..]);
380 }
381 message.push('\n');
382 continue;
383 }
384
385 if trimmed.len() > 4 {
386 message = trimmed[4..].to_string();
387 }
388 break;
389 }
390
391 let code_val = code.unwrap_or(0);
392 debug!("FTP RESP: {} {}", code_val, message.trim());
393 Ok((code_val, message))
394 }
395
396 async fn command(&mut self, cmd: &str) -> Result<(u16, String)> {
398 self.send_command(cmd).await?;
399 self.read_response(Duration::from_secs(constants::FTP_COMMAND_TIMEOUT_SECS))
400 .await
401 }
402
403 async fn authenticate(&mut self, username: &str, password: &str) -> Result<()> {
405 info!("Authenticating as user: {}", username);
406
407 let user_resp = self.command(&format!("USER {}", username)).await?;
408 match user_resp.0 {
409 230 => {
410 info!("FTP login successful (no password required)");
412 Ok(())
413 }
414 331 | 332 => {
415 debug!("Password required, sending PASS command");
417 let pass_resp = self.command(&format!("PASS {}", password)).await?;
418 if !(200..300).contains(&pass_resp.0) {
419 return Err(Aria2Error::Fatal(FatalError::PermissionDenied {
420 path: format!("Login failed: {} {}", pass_resp.0, pass_resp.1),
421 }));
422 }
423 info!("FTP login successful");
424 Ok(())
425 }
426 _ => Err(Aria2Error::Fatal(FatalError::PermissionDenied {
427 path: format!("Unexpected USER response: {} {}", user_resp.0, user_resp.1),
428 })),
429 }
430 }
431
432 async fn set_binary_mode(&mut self) -> Result<()> {
434 debug!("Setting transfer mode to binary (TYPE I)");
435 let resp = self.command("TYPE I").await?;
436 if !(200..300).contains(&resp.0) {
437 return Err(Aria2Error::Recoverable(
438 RecoverableError::TemporaryNetworkFailure {
439 message: format!("TYPE I failed: {} {}", resp.0, resp.1),
440 },
441 ));
442 }
443 Ok(())
444 }
445
446 async fn set_resume_offset(&mut self, offset: u64) -> Result<()> {
448 if offset == 0 {
449 return Ok(());
450 }
451 debug!("Setting resume offset: {} bytes", offset);
452 let resp = self.command(&format!("REST {}", offset)).await?;
453 if resp.0 != 350 {
454 warn!("REST command not accepted by server: {} {}", resp.0, resp.1);
455 return Ok(());
457 }
458 Ok(())
459 }
460
461 async fn get_file_size(&mut self, remote_path: &str) -> Result<Option<u64>> {
463 debug!("Querying file size: {}", remote_path);
464 let resp = self.command(&format!("SIZE {}", remote_path)).await?;
465 if resp.0 == 213 {
466 let size: u64 = resp.1.trim().parse().unwrap_or(0);
467 debug!("File size: {} bytes", size);
468 return Ok(Some(size));
469 }
470 debug!("SIZE command returned: {} {}", resp.0, resp.1);
472 Ok(None)
473 }
474
475 async fn enter_passive_mode(&mut self) -> Result<(String, u16)> {
477 debug!("Attempting extended passive mode (EPSV)");
479 let epsv_resp = self.command("EPSV").await;
480
481 match epsv_resp {
482 Ok(resp) if resp.0 == 229 => {
483 if let Some(port) = parse_epsv_response(&resp.1) {
485 debug!("EPSV successful, using port: {}", port);
486 return Ok((self.host.clone(), port));
487 }
488 warn!("Failed to parse EPSV response, falling back to PASV");
489 }
490 _ => {
491 debug!("EPSV not supported, trying PASV");
492 }
493 }
494
495 debug!("Entering passive mode (PASV)");
497 let pasv_resp = self.command("PASV").await?;
498 if pasv_resp.0 != 227 {
499 return Err(Aria2Error::Recoverable(
500 RecoverableError::TemporaryNetworkFailure {
501 message: format!("PASV failed: {} {}", pasv_resp.0, pasv_resp.1),
502 },
503 ));
504 }
505
506 match parse_pasv_response(&pasv_resp.1) {
507 Some((host, port)) => {
508 debug!("PASV successful, data channel: {}:{}", host, port);
509 Ok((host, port))
510 }
511 None => Err(Aria2Error::Recoverable(
512 RecoverableError::TemporaryNetworkFailure {
513 message: "Cannot parse PASV response".into(),
514 },
515 )),
516 }
517 }
518
519 async fn initiate_retr(&mut self, remote_path: &str) -> Result<()> {
521 debug!("Initiating file retrieval: {}", remote_path);
522 let resp = self.command(&format!("RETR {}", remote_path)).await?;
523 if resp.0 != 150 && resp.0 != 125 {
524 return Err(Aria2Error::Recoverable(
525 RecoverableError::TemporaryNetworkFailure {
526 message: format!("RETR unexpected response: {} {}", resp.0, resp.1),
527 },
528 ));
529 }
530 Ok(())
531 }
532
533 async fn read_transfer_complete(&mut self) -> Result<()> {
535 match self
536 .read_response(Duration::from_secs(
537 constants::FTP_TRANSFER_COMPLETE_TIMEOUT_SECS,
538 ))
539 .await
540 {
541 Ok((226, msg)) => {
542 debug!("Transfer complete: {}", msg);
543 Ok(())
544 }
545 Ok((code, msg)) => {
546 warn!("Transfer response non-226: {} {}", code, msg);
547 Ok(())
549 }
550 Err(e) => {
551 debug!("Transfer completion timeout/error (may be normal): {}", e);
552 Ok(())
553 }
554 }
555 }
556
557 async fn quit(mut self) -> Result<()> {
559 debug!("Sending QUIT command");
560 let _ = self.command("QUIT").await.ok(); Ok(())
562 }
563}
564
565fn parse_pasv_response(response: &str) -> Option<(String, u16)> {
567 let start = response.find('(')?;
568 let end = response.rfind(')')?;
569 let inner = &response[start + 1..end];
570 let parts: Vec<&str> = inner.split(',').collect();
571 if parts.len() != 6 {
572 return None;
573 }
574 let h1: u8 = parts[0].trim().parse().ok()?;
575 let h2: u8 = parts[1].trim().parse().ok()?;
576 let h3: u8 = parts[2].trim().parse().ok()?;
577 let h4: u8 = parts[3].trim().parse().ok()?;
578 let p1: u16 = parts[4].trim().parse().ok()?;
579 let p2: u16 = parts[5].trim().parse().ok()?;
580 Some((format!("{}.{}.{}.{}", h1, h2, h3, h4), p1 * 256 + p2))
581}
582
583fn parse_epsv_response(response: &str) -> Option<u16> {
585 let start = response.rfind('|')?;
586 let prev_pipe = response[..start].rfind('|')?;
587 let port_str = &response[prev_pipe + 1..start];
588 port_str.parse::<u16>().ok()
589}
590
591#[async_trait]
592impl Command for FtpDownloadCommand {
593 async fn execute(&mut self) -> Result<()> {
595 if !self.started {
596 self.group.write().await.start().await?;
597 self.started = true;
598 }
599
600 info!(
601 "FTP download starting: {}:{} -> {}",
602 self.host,
603 self.port,
604 self.output_path.display()
605 );
606
607 if let Some(parent) = self.output_path.parent()
609 && !parent.exists()
610 {
611 std::fs::create_dir_all(parent).map_err(|e| {
612 Aria2Error::Fatal(FatalError::Config(format!("mkdir failed: {}", e)))
613 })?;
614 }
615
616 loop {
618 match self.execute_single_attempt().await {
619 Ok(_) => {
620 info!(
621 "FTP download completed successfully: {} ({} bytes)",
622 self.output_path.display(),
623 self.completed_bytes
624 );
625 return Ok(());
626 }
627 Err(e) => {
628 let should_retry = matches!(
630 e,
631 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure { .. })
632 | Aria2Error::Recoverable(RecoverableError::Timeout)
633 ) && self.current_retry < self.max_retries;
634
635 if should_retry {
636 self.current_retry += 1;
637 let wait_ms =
638 constants::FTP_BASE_RETRY_WAIT_MS * (1 << (self.current_retry - 1));
639 warn!(
640 "FTP download failed (attempt {}/{}), retrying in {}ms: {}",
641 self.current_retry, self.max_retries, wait_ms, e
642 );
643 tokio::time::sleep(Duration::from_millis(wait_ms)).await;
644
645 self.completed_bytes = 0;
647 continue;
648 }
649
650 error!(
652 "FTP download failed permanently after {} attempts: {}",
653 self.current_retry + 1,
654 e
655 );
656 return Err(e);
657 }
658 }
659 }
660 }
661
662 fn status(&self) -> CommandStatus {
663 if self.completed_bytes > 0 || self.started {
664 CommandStatus::Running
665 } else {
666 CommandStatus::Pending
667 }
668 }
669
670 fn timeout(&self) -> Option<Duration> {
671 Some(Duration::from_secs(
672 constants::FTP_DEFAULT_COMMAND_TIMEOUT_SECS,
673 ))
674 }
675}
676
677impl FtpDownloadCommand {
678 async fn execute_single_attempt(&mut self) -> Result<()> {
680 let mut ctrl = RawFtpControl::connect(&self.host, self.port).await?;
682
683 ctrl.authenticate(&self.username, &self.password).await?;
685
686 ctrl.set_binary_mode().await?;
688
689 let file_size = ctrl.get_file_size(&self.remote_path).await?;
691
692 {
694 let mut g = self.group.write().await;
695 g.set_total_length(file_size.unwrap_or(0)).await;
696 }
697
698 if self.resume_offset > 0 {
700 ctrl.set_resume_offset(self.resume_offset).await?;
701 }
702
703 let (data_host, data_port) = if self.passive_mode {
705 ctrl.enter_passive_mode().await?
706 } else {
707 return Err(Aria2Error::Recoverable(
709 RecoverableError::TemporaryNetworkFailure {
710 message: "Active mode not yet implemented".into(),
711 },
712 ));
713 };
714
715 ctrl.initiate_retr(&self.remote_path).await?;
717
718 let data_addr: std::net::SocketAddr = format!("{}:{}", data_host, data_port)
720 .parse()
721 .map_err(|_| {
722 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
723 message: "Invalid data address".into(),
724 })
725 })?;
726
727 let mut data_stream = tokio::time::timeout(
728 Duration::from_secs(constants::FTP_DATA_CONNECTION_TIMEOUT_SECS),
729 tokio::net::TcpStream::connect(data_addr),
730 )
731 .await
732 .map_err(|_| {
733 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
734 message: "Data connection timeout".into(),
735 })
736 })?
737 .map_err(|e| {
738 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
739 message: format!("Data connection failed: {}", e),
740 })
741 })?;
742
743 let _ = data_stream.set_nodelay(true); let raw_writer = DefaultDiskWriter::new(&self.output_path);
748 let rate_limit = {
749 let g = self.group.read().await;
750 g.options().max_download_limit
751 };
752 let mut writer: Box<dyn DiskWriter> = if let Some(rate) = rate_limit.filter(|&r| r > 0) {
753 debug!("Rate limiting enabled: {} bytes/sec", rate);
754 Box::new(ThrottledWriter::new(
755 raw_writer,
756 RateLimiter::new(&RateLimiterConfig::new(Some(rate), None)),
757 ))
758 } else {
759 Box::new(raw_writer)
760 };
761
762 if self.resume_offset > 0 {
767 debug!(
768 "Resume offset: {} bytes (using FTP REST command)",
769 self.resume_offset
770 );
771 }
772
773 let mut buffer = vec![0u8; constants::FTP_BUFFER_SIZE];
775 let start_time = Instant::now();
776 let mut last_speed_update = Instant::now();
777 let mut last_completed = 0u64;
778
779 info!("Starting data reception from FTP server");
780
781 loop {
782 let bytes_read = data_stream.read(&mut buffer).await.map_err(|e| {
783 use std::io::ErrorKind;
785 match e.kind() {
786 ErrorKind::Interrupted
787 | ErrorKind::WouldBlock
788 | ErrorKind::ConnectionReset
789 | ErrorKind::ConnectionAborted
790 | ErrorKind::BrokenPipe
791 | ErrorKind::TimedOut => {
792 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
793 message: format!("Data read error (transient): {}", e),
794 })
795 }
796 _ => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
797 message: format!("Data read error: {}", e),
798 }),
799 }
800 })?;
801
802 if bytes_read == 0 {
803 debug!("End of data stream reached");
804 break;
805 }
806
807 writer.write(&buffer[..bytes_read]).await?;
809 self.completed_bytes += bytes_read as u64;
810
811 {
813 let g = self.group.write().await;
814 g.update_progress(self.completed_bytes).await;
815
816 let elapsed = last_speed_update.elapsed();
818 if elapsed.as_millis() >= constants::FTP_SPEED_UPDATE_INTERVAL_MS as u128 {
819 let delta = self.completed_bytes - last_completed;
820 let speed = if elapsed.as_secs_f64() > 0.0 {
821 (delta as f64 / elapsed.as_secs_f64()) as u64
822 } else {
823 0
824 };
825 g.update_speed(speed, 0).await;
826 last_speed_update = Instant::now();
827 last_completed = self.completed_bytes;
828 }
829 }
830 }
831
832 drop(data_stream); writer.finalize().await.map_err(|e| {
837 Aria2Error::Fatal(FatalError::Config(format!("Finalize writer failed: {}", e)))
838 })?;
839
840 ctrl.read_transfer_complete().await?;
842
843 ctrl.quit().await.ok();
845
846 let final_speed = {
848 let elapsed = start_time.elapsed().as_secs_f64();
849 if elapsed > 0.0 {
850 (self.completed_bytes as f64 / elapsed) as u64
851 } else {
852 0
853 }
854 };
855
856 {
858 let mut g = self.group.write().await;
859 g.update_progress(self.completed_bytes).await;
860 g.update_speed(final_speed, 0).await;
861 g.complete().await?;
862 }
863
864 Ok(())
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 #[test]
873 fn test_parse_uri_simple() {
874 let uri = "ftp://example.com/file.txt";
875 let result = FtpDownloadCommand::parse_uri(uri).unwrap();
876 assert_eq!(result.0, "example.com");
877 assert_eq!(result.1, 21);
878 assert_eq!(result.2, "anonymous");
879 assert_eq!(result.3, "aria2@");
880 assert_eq!(result.4, "/file.txt");
881 }
882
883 #[test]
884 fn test_parse_uri_with_port() {
885 let uri = "ftp://example.com:2121/file.txt";
886 let result = FtpDownloadCommand::parse_uri(uri).unwrap();
887 assert_eq!(result.0, "example.com");
888 assert_eq!(result.1, 2121);
889 }
890
891 #[test]
892 fn test_parse_uri_with_auth() {
893 let uri = "ftp://user:pass@example.com/file.txt";
894 let result = FtpDownloadCommand::parse_uri(uri).unwrap();
895 assert_eq!(result.2, "user");
896 assert_eq!(result.3, "pass");
897 }
898
899 #[test]
900 fn test_parse_uri_with_encoded_chars() {
901 let uri = "ftp://example.com/my%20file.txt";
902 let result = FtpDownloadCommand::parse_uri(uri).unwrap();
903 assert_eq!(result.4, "/my file.txt");
904 }
905
906 #[test]
907 fn test_parse_uri_invalid_protocol() {
908 let uri = "http://example.com/file.txt";
909 let result = FtpDownloadCommand::parse_uri(uri);
910 assert!(result.is_err());
911 }
912
913 #[test]
914 fn test_extract_filename_from_path() {
915 assert_eq!(
916 FtpDownloadCommand::extract_filename("/path/to/file.txt"),
917 Some("file.txt".to_string())
918 );
919 assert_eq!(
920 FtpDownloadCommand::extract_filename("/file.txt"),
921 Some("file.txt".to_string())
922 );
923 assert_eq!(FtpDownloadCommand::extract_filename("/"), None);
924 assert_eq!(FtpDownloadCommand::extract_filename(""), None);
925 }
926
927 #[test]
928 fn test_urlencoding_decode() {
929 assert_eq!(urlencoding_decode("hello%20world"), "hello world");
930 assert_eq!(urlencoding_decode("%2F"), "/");
931 assert_eq!(urlencoding_decode("normal"), "normal");
932 assert_eq!(urlencoding_decode("%41"), "A");
933 }
934
935 #[test]
936 fn test_parse_pasv_response_standard() {
937 let resp = "227 Entering Passive Mode (192,168,1,100,200,10)";
938 let result = parse_pasv_response(resp).unwrap();
939 assert_eq!(result.0, "192.168.1.100");
940 assert_eq!(result.1, 200 * 256 + 10); }
942
943 #[test]
944 fn test_parse_pasv_response_minimal() {
945 let resp = "(10,0,0,1,0,21)";
946 let result = parse_pasv_response(resp).unwrap();
947 assert_eq!(result.0, "10.0.0.1");
948 assert_eq!(result.1, 21);
949 }
950
951 #[test]
952 fn test_parse_pasv_response_invalid() {
953 assert!(parse_pasv_response("no parentheses").is_none());
954 assert!(parse_pasv_response("(1,2,3)").is_none()); }
956
957 #[test]
958 fn test_parse_epsv_response_standard() {
959 let resp = "229 Entering Extended Passive Mode (|||50001|)";
960 let result = parse_epsv_response(resp).unwrap();
961 assert_eq!(result, 50001);
962 }
963
964 #[test]
965 fn test_parse_epsv_response_minimal() {
966 let resp = "|||60000|";
967 let result = parse_epsv_response(resp).unwrap();
968 assert_eq!(result, 60000);
969 }
970
971 #[test]
972 fn test_classify_ftp_error_transient() {
973 let transient_codes = [421u16, 425, 426, 450, 451, 452];
975 for code in transient_codes {
976 assert!(
979 (400..=499).contains(&code),
980 "Code {} should be in transient range",
981 code
982 );
983 }
984 }
985
986 #[test]
987 fn test_classify_ftp_error_permanent() {
988 let permanent_codes = [500u16, 501, 502, 503, 504, 530, 550, 553];
990 for code in permanent_codes {
991 assert!(
992 (500..=599).contains(&code),
993 "Code {} should be in permanent range",
994 code
995 );
996 }
997 }
998
999 #[test]
1000 fn test_resume_offset_calculation() {
1001 let path = std::path::PathBuf::from("/tmp/test_file");
1004 if path.exists() {
1005 let metadata = std::fs::metadata(&path).unwrap();
1006 let _offset = metadata.len();
1007 } else {
1009 assert_eq!(0u64, 0);
1011 }
1012 }
1013
1014 #[tokio::test]
1015 async fn test_raw_ftp_control_connect_invalid_address() {
1016 let result = RawFtpControl::connect("invalid.host.name.invalid", 21).await;
1017 assert!(result.is_err());
1018 }
1019}