Skip to main content

aria2_core/engine/
ftp_download_command.rs

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
14/// FTP download command that handles the complete download lifecycle
15pub 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 for partial downloads (0 if not resuming)
26    resume_offset: u64,
27    /// Whether to use passive mode (true) or active mode (false)
28    passive_mode: bool,
29    /// Maximum number of retry attempts for transient errors
30    max_retries: u32,
31    /// Current retry attempt count
32    current_retry: u32,
33}
34
35impl FtpDownloadCommand {
36    /// Create a new FTP download command
37    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        // Check if file exists for resume support
59        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, // Default to passive mode
88            max_retries: constants::DEFAULT_MAX_RETRIES,
89            current_retry: 0,
90        })
91    }
92
93    /// Parse FTP URI into components
94    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    /// Extract filename from remote path
143    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    /// Get read access to the request group
152    pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
153        self.group.read().await
154    }
155
156    /// Classify FTP response code to determine error handling strategy
157    #[allow(dead_code)] // Must remain: will be used when FTP retry-with-classification logic is integrated into execute_single_attempt
158    fn classify_ftp_error(&self, code: u16, message: &str) -> Aria2Error {
159        match code {
160            // Positive responses (should not be errors)
161            100..=399 => Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
162                message: format!("Unexpected positive response: {} {}", code, message),
163            }),
164            // Transient negative completion - retry may succeed
165            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            // Permanent negative completion - do not retry
184            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            // Unknown error codes
209            _ => {
210                // Check message content for hints about error type
211                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                    // Default to recoverable for unknown codes in 4xx/5xx range
226                    Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
227                        message: format!("FTP error {} {}: {}", code, message, self.remote_path),
228                    })
229                }
230            }
231        }
232    }
233}
234
235/// URL-encoded string decoder
236fn 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
255/// Raw FTP control connection handler
256struct RawFtpControl {
257    reader: BufReader<tokio::net::TcpStream>,
258    host: String,
259}
260
261impl RawFtpControl {
262    /// Establish connection to FTP server and read welcome message
263    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        // Set TCP keepalive and no-delay options
282        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    /// Send a command to the FTP server
308    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    /// Read response from FTP server with timeout
328    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    /// Send command and read response in one operation
397    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    /// Authenticate with USER/PASS commands
404    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                // Login successful without password
411                info!("FTP login successful (no password required)");
412                Ok(())
413            }
414            331 | 332 => {
415                // Password required
416                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    /// Set binary transfer mode (TYPE I)
433    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    /// Set resume offset (REST command)
447    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            // Some servers don't support REST, continue without resume
456            return Ok(());
457        }
458        Ok(())
459    }
460
461    /// Get file size (SIZE command)
462    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        // SIZE command may not be supported by all servers
471        debug!("SIZE command returned: {} {}", resp.0, resp.1);
472        Ok(None)
473    }
474
475    /// Enter passive mode (PASV/EPSV)
476    async fn enter_passive_mode(&mut self) -> Result<(String, u16)> {
477        // Try EPSV first (supports IPv6), fallback to PASV
478        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                // Parse |||port| format
484                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        // Fallback to PASV
496        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    /// Initiate file retrieval (RETR command)
520    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    /// Read final transfer completion response
534    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                // Some servers don't send 226 properly, but data was received OK
548                Ok(())
549            }
550            Err(e) => {
551                debug!("Transfer completion timeout/error (may be normal): {}", e);
552                Ok(())
553            }
554        }
555    }
556
557    /// Gracefully disconnect from server
558    async fn quit(mut self) -> Result<()> {
559        debug!("Sending QUIT command");
560        let _ = self.command("QUIT").await.ok(); // Ignore errors on quit
561        Ok(())
562    }
563}
564
565/// Parse PASV response to extract IP and port
566fn 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
583/// Parse EPSV response to extract port
584fn 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    /// Execute the FTP download with full lifecycle management
594    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        // Create output directory if needed
608        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        // Retry loop for transient errors
617        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                    // Check if this is a retry-worthy error
629                    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                        // Reset state for retry
646                        self.completed_bytes = 0;
647                        continue;
648                    }
649
650                    // Non-retryable error or max retries exceeded
651                    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    /// Execute a single download attempt
679    async fn execute_single_attempt(&mut self) -> Result<()> {
680        // Step 1: Connect to FTP server
681        let mut ctrl = RawFtpControl::connect(&self.host, self.port).await?;
682
683        // Step 2: Authenticate
684        ctrl.authenticate(&self.username, &self.password).await?;
685
686        // Step 3: Set binary transfer mode
687        ctrl.set_binary_mode().await?;
688
689        // Step 4: Probe file size
690        let file_size = ctrl.get_file_size(&self.remote_path).await?;
691
692        // Update total length in request group
693        {
694            let mut g = self.group.write().await;
695            g.set_total_length(file_size.unwrap_or(0)).await;
696        }
697
698        // Step 5: Set resume offset if applicable
699        if self.resume_offset > 0 {
700            ctrl.set_resume_offset(self.resume_offset).await?;
701        }
702
703        // Step 6: Negotiate data connection mode
704        let (data_host, data_port) = if self.passive_mode {
705            ctrl.enter_passive_mode().await?
706        } else {
707            // Active mode would go here (not fully implemented in this version)
708            return Err(Aria2Error::Recoverable(
709                RecoverableError::TemporaryNetworkFailure {
710                    message: "Active mode not yet implemented".into(),
711                },
712            ));
713        };
714
715        // Step 7: Initiate file transfer (RETR)
716        ctrl.initiate_retr(&self.remote_path).await?;
717
718        // Step 8: Connect to data port
719        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        // Set TCP no-delay on data connection
744        let _ = data_stream.set_nodelay(true); // Ignore error if not supported
745
746        // Step 9: Setup disk writer with optional rate limiting
747        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        // Seek to resume offset if resuming
763        // Note: DiskWriter trait doesn't support seek, so for resume we rely on
764        // the FTP REST command to tell server to start from the offset,
765        // and data will be appended to existing file if it exists
766        if self.resume_offset > 0 {
767            debug!(
768                "Resume offset: {} bytes (using FTP REST command)",
769                self.resume_offset
770            );
771        }
772
773        // Step 10: Data receive loop with progress tracking
774        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                // Classify IO errors
784                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            // Write to disk (with rate limiting if enabled)
808            writer.write(&buffer[..bytes_read]).await?;
809            self.completed_bytes += bytes_read as u64;
810
811            // Update progress in request group
812            {
813                let g = self.group.write().await;
814                g.update_progress(self.completed_bytes).await;
815
816                // Update speed calculation every 500ms
817                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        // Step 11: Cleanup and finalize
833        drop(data_stream); // Close data connection
834
835        // Finalize disk writer (flush buffers, etc.)
836        writer.finalize().await.map_err(|e| {
837            Aria2Error::Fatal(FatalError::Config(format!("Finalize writer failed: {}", e)))
838        })?;
839
840        // Read transfer completion response from control channel
841        ctrl.read_transfer_complete().await?;
842
843        // Disconnect gracefully
844        ctrl.quit().await.ok();
845
846        // Calculate final statistics
847        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        // Update final status in request group
857        {
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); // 51210
941    }
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()); // Too few parts
955    }
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        // These should be classified as transient/recoverable
974        let transient_codes = [421u16, 425, 426, 450, 451, 452];
975        for code in transient_codes {
976            // Note: classify_ftp_error is a method on FtpDownloadCommand
977            // We can't easily test it without an instance, but the logic is clear
978            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        // These should be classified as permanent/fatal
989        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        // Test that resume offset would be calculated correctly from existing file
1002        // (This logic is in new(), so we verify the concept)
1003        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            // offset is u64, always >= 0 by type guarantee
1008        } else {
1009            // File doesn't exist, offset should be 0
1010            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}