Skip to main content

aria2_core/ftp/
connection.rs

1//! FTP 协议客户端实现
2//!
3//! 提供异步 FTP 客户端,支持被动/主动模式、二进制传输、目录列表解析等功能。
4
5use crate::error::{Aria2Error, Result};
6use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
7use tokio::net::TcpStream;
8use tokio::time::{Duration, timeout};
9use tracing::{debug, info, warn};
10
11/// FTP 数据连接模式
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum FtpMode {
14    /// 被动模式(客户端连接服务器数据端口)
15    #[default]
16    Passive,
17    /// 主动模式(服务器连接客户端数据端口)
18    Active,
19}
20
21/// FTP 响应结构体
22#[derive(Debug, Clone)]
23pub struct FtpResponse {
24    /// FTP 响应码(3位数字)
25    pub code: u16,
26    /// 响应消息文本
27    pub message: String,
28}
29
30impl FtpResponse {
31    /// 检查是否为成功响应(1xx-3xx)
32    pub fn is_success(&self) -> bool {
33        (100..400).contains(&self.code)
34    }
35
36    /// 检查是否为中间响应(1xx)
37    pub fn is_intermediate(&self) -> bool {
38        (100..200).contains(&self.code)
39    }
40
41    /// 检查是否为正向完成响应(2xx)
42    pub fn is_positive_completion(&self) -> bool {
43        (200..300).contains(&self.code)
44    }
45
46    /// 检查是否为正向预备响应(1xx)
47    pub fn is_positive_preliminary(&self) -> bool {
48        (100..200).contains(&self.code)
49    }
50}
51
52/// FTP 文件信息结构体
53#[derive(Debug, Clone)]
54pub struct FtpFileInfo {
55    /// 文件或目录名称
56    pub name: String,
57    /// 文件大小(字节),目录时为 0
58    pub size: u64,
59    /// 是否为目录
60    pub is_dir: bool,
61}
62
63/// FTP 客户端
64///
65/// 异步 FTP 协议实现,支持:
66/// - 被动模式优先,主动模式 fallback
67/// - 二进制/ASCII 传输模式切换
68/// - 断点续传(REST 命令)
69/// - 目录列表解析(Unix/Windows 格式)
70///
71/// # 示例
72///
73/// ```rust,no_run
74/// use aria2_core::ftp::connection::{FtpClient, FtpMode};
75///
76/// #[tokio::main]
77/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
78///     let mut client = FtpClient::connect("ftp.example.com", 21, FtpMode::Passive).await?;
79///     client.login("anonymous", "user@example.com").await?;
80///     client.set_binary_mode(true).await?;
81///
82///     let files = client.list_directory("/").await?;
83///     for file in &files {
84///         println!("{} {} {}", if file.is_dir { "D" } else { "F" }, file.size, file.name);
85///     }
86///
87///     client.quit().await?;
88///     Ok(())
89/// }
90/// ```
91pub struct FtpClient {
92    /// 控制连接流(带缓冲)
93    pub(crate) control_stream: BufReader<TcpStream>,
94    /// 数据连接模式
95    pub(crate) mode: FtpMode,
96    /// 当前二进制模式状态
97    pub(crate) binary_mode: bool,
98    /// 服务器主机地址
99    pub(crate) host: String,
100    /// 服务器端口
101    #[allow(dead_code)] // Port field retained for FTP connection configuration
102    pub(crate) port: u16,
103    /// 连接超时时间
104    pub(crate) connect_timeout: Duration,
105    /// 读取超时时间
106    pub(crate) read_timeout: Duration,
107}
108
109impl FtpClient {
110    /// 默认连接超时:30 秒
111    const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
112    /// 默认读取超时:30 秒
113    const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
114
115    /// 连接到 FTP 服务器
116    ///
117    /// # 参数
118    ///
119    /// - `host`: FTP 服务器地址(域名或 IP)
120    /// - `port`: FTP 服务器端口(通常为 21)
121    /// - `mode`: 数据连接模式(被动或主动)
122    ///
123    /// # 错误
124    ///
125    /// - 连接超时
126    /// - 网络错误
127    /// - 服务器拒绝连接
128    pub async fn connect(host: &str, port: u16, mode: FtpMode) -> Result<Self> {
129        info!("FTP 连接中: {}:{}", host, port);
130
131        let stream = timeout(
132            Self::DEFAULT_CONNECT_TIMEOUT,
133            TcpStream::connect((host, port)),
134        )
135        .await
136        .map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
137        .map_err(|e| Aria2Error::Network(format!("FTP 连接失败: {}", e)))?;
138
139        let mut client = Self {
140            control_stream: BufReader::new(stream),
141            mode,
142            binary_mode: false,
143            host: host.to_string(),
144            port,
145            connect_timeout: Self::DEFAULT_CONNECT_TIMEOUT,
146            read_timeout: Self::DEFAULT_READ_TIMEOUT,
147        };
148
149        // 读取欢迎消息
150        let welcome = client.read_response().await?;
151        if !welcome.is_positive_completion() && !welcome.is_positive_preliminary() {
152            return Err(Aria2Error::DownloadFailed(format!(
153                "FTP 服务器拒绝连接: {} {}",
154                welcome.code, welcome.message
155            )));
156        }
157
158        debug!("FTP 连接成功: {}", welcome.message.trim());
159        Ok(client)
160    }
161
162    /// 登录到 FTP 服务器
163    ///
164    /// # 参数
165    ///
166    /// - `username`: 用户名(匿名登录使用 "anonymous")
167    /// - `password`: 密码(匿名登录使用邮箱)
168    ///
169    /// # 错误
170    ///
171    /// - 530 未登录(认证失败)
172    pub async fn login(&mut self, username: &str, password: &str) -> Result<()> {
173        debug!("发送 USER 命令: {}", username);
174        self.send_command(&format!("USER {}", username)).await?;
175        let resp = self.read_response().await?;
176
177        match resp.code {
178            230 => {
179                // 无需密码,直接登录成功
180                info!("FTP 登录成功 (无需密码)");
181                Ok(())
182            }
183            331 | 332 => {
184                // 需要密码
185                debug!("需要密码认证,发送 PASS 命令");
186                self.send_command(&format!("PASS {}", password)).await?;
187                let pass_resp = self.read_response().await?;
188
189                if pass_resp.code == 230 || pass_resp.code == 202 {
190                    info!("FTP 登录成功");
191                    Ok(())
192                } else if pass_resp.code == 530 {
193                    Err(Aria2Error::Recoverable(
194                        crate::error::RecoverableError::ServerError { code: 530 },
195                    ))
196                } else {
197                    Err(Aria2Error::DownloadFailed(format!(
198                        "FTP 登录失败: {} {}",
199                        pass_resp.code, pass_resp.message
200                    )))
201                }
202            }
203            530 => Err(Aria2Error::Recoverable(
204                crate::error::RecoverableError::ServerError { code: 530 },
205            )),
206            _ => {
207                if resp.is_positive_completion() {
208                    info!("FTP 登录成功");
209                    Ok(())
210                } else {
211                    Err(Aria2Error::DownloadFailed(format!(
212                        "FTP 登录失败: {} {}",
213                        resp.code, resp.message
214                    )))
215                }
216            }
217        }
218    }
219
220    /// 设置传输模式(二进制/ASCII)
221    ///
222    /// # 参数
223    ///
224    /// - `enabled`: true 为二进制模式(TYPE I),false 为 ASCII 模式(TYPE A)
225    ///
226    /// # 错误
227    ///
228    /// - 504 不支持的传输模式
229    pub async fn set_binary_mode(&mut self, enabled: bool) -> Result<()> {
230        let type_cmd = if enabled { "TYPE I" } else { "TYPE A" };
231        debug!("设置传输类型: {}", type_cmd);
232        self.send_command(type_cmd).await?;
233        let resp = self.read_response().await?;
234
235        if resp.is_positive_completion() {
236            self.binary_mode = enabled;
237            debug!(
238                "传输模式设置为: {}",
239                if enabled { "Binary" } else { "ASCII" }
240            );
241            Ok(())
242        } else if resp.code == 504 {
243            Err(Aria2Error::DownloadFailed(format!(
244                "不支持的传输模式: {}",
245                resp.message
246            )))
247        } else {
248            Err(Aria2Error::DownloadFailed(format!(
249                "TYPE 命令失败: {} {}",
250                resp.code, resp.message
251            )))
252        }
253    }
254
255    /// 进入被动模式并建立数据连接
256    ///
257    /// 优先尝试 EPSV(扩展被动模式),如果服务器不支持则回退到 PASV。
258    ///
259    /// # 返回
260    ///
261    /// 返回数据连接的 TcpStream
262    ///
263    /// # 错误
264    ///
265    /// - 425 无法打开数据连接
266    /// - 超时错误
267    pub async fn passive_mode(&mut self) -> Result<TcpStream> {
268        debug!("请求被动模式数据连接");
269
270        // 先尝试 EPSV
271        self.send_command("EPSV").await?;
272        let resp = self.read_response().await?;
273
274        if resp.code == 229 {
275            // 解析 EPSV 响应: Entering Extended Passive Mode (|||port|)
276            if let Some(port) = Self::parse_epsv_response(&resp.message) {
277                debug!("EPSV 数据通道端口: {}", port);
278                let data_stream = timeout(
279                    self.connect_timeout,
280                    TcpStream::connect((self.host.as_str(), port)),
281                )
282                .await
283                .map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
284                .map_err(|e| Aria2Error::Network(format!("EPSV 数据连接失败: {}", e)))?;
285
286                return Ok(data_stream);
287            }
288        }
289
290        // 回退到 PASV
291        warn!("EPSV 不可用,回退到 PASV 模式");
292        self.send_command("PASV").await?;
293        let pasv_resp = self.read_response().await?;
294
295        if pasv_resp.code != 227 {
296            return Err(Aria2Error::Recoverable(
297                crate::error::RecoverableError::ServerError { code: 425 },
298            ));
299        }
300
301        // 解析 PASV 响应
302        let (data_host, data_port) = Self::parse_pasv_response(&pasv_resp.message)?;
303        debug!("PASV 数据通道: {}:{}", data_host, data_port);
304        let data_stream = timeout(
305            self.connect_timeout,
306            TcpStream::connect((data_host.as_str(), data_port)),
307        )
308        .await
309        .map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
310        .map_err(|e| Aria2Error::Network(format!("PASV 数据连接失败: {}", e)))?;
311        Ok(data_stream)
312    }
313
314    /// 进入主动模式并建立数据连接
315    ///
316    /// 发送 PORT 或 EPRT 命令告知服务器客户端的数据端口,
317    /// 然后在本地监听该端口等待服务器连接。
318    ///
319    /// # 返回
320    ///
321    /// 返回已接受的数据连接 TcpStream
322    ///
323    /// # 错误
324    ///
325    /// - 425 无法打开数据连接
326    /// - 500/501/502 命令语法错误
327    pub async fn active_mode(&mut self) -> Result<TcpStream> {
328        debug!("请求主动模式数据连接");
329
330        // 获取本地地址
331        let local_addr = self
332            .control_stream
333            .get_ref()
334            .local_addr()
335            .map_err(|e| Aria2Error::Network(format!("获取本地地址失败: {}", e)))?;
336
337        // 在端口 0 上监听(系统自动分配可用端口)
338        let listener = tokio::net::TcpListener::bind("0.0.0.0:0")
339            .await
340            .map_err(|e| Aria2Error::Network(format!("绑定数据端口失败: {}", e)))?;
341        let data_port = listener
342            .local_addr()
343            .map_err(|e| Aria2Error::Network(format!("获取监听端口失败: {}", e)))?
344            .port();
345
346        let local_ip = local_addr.ip();
347
348        // 尝试 EPRT(扩展主动模式)
349        let eprt_cmd = format!("EPRT |1|{}|{}|", local_ip, data_port);
350        debug!("发送 EPRT 命令: {}", eprt_cmd);
351        self.send_command(&eprt_cmd).await?;
352        let resp = self.read_response().await?;
353
354        if resp.code != 200 && resp.code != 500 && resp.code != 501 && resp.code != 502 {
355            return Err(Aria2Error::DownloadFailed(format!(
356                "EPRT 命令失败: {} {}",
357                resp.code, resp.message
358            )));
359        }
360
361        // 如果 EPRT 失败,尝试 PORT 命令
362        if !resp.is_positive_completion() {
363            warn!("EPRT 不可用,回退到 PORT 模式");
364
365            // 将 IP 地址转换为 PORT 命令格式 (h1,h2,h3,h4,p1,p2)
366            // 只支持 IPv4(PORT 命令不支持 IPv6)
367
368            let ipv4_addr = match local_ip {
369                std::net::IpAddr::V4(v4) => v4,
370                std::net::IpAddr::V6(_) => {
371                    // IPv6 不支持 PORT 命令,返回错误
372                    return Err(Aria2Error::DownloadFailed(
373                        "IPv6 不支持主动模式 PORT 命令,请使用被动模式".to_string(),
374                    ));
375                }
376            };
377            let ip_bytes = ipv4_addr.octets();
378            let p1 = data_port / 256;
379            let p2 = data_port % 256;
380            let port_cmd = format!(
381                "PORT {},{},{},{},{},{}",
382                ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3], p1, p2
383            );
384
385            debug!("发送 PORT 命令: {}", port_cmd);
386            self.send_command(&port_cmd).await?;
387            let port_resp = self.read_response().await?;
388
389            if !port_resp.is_positive_completion() {
390                return Err(Aria2Error::Recoverable(
391                    crate::error::RecoverableError::ServerError { code: 425 },
392                ));
393            }
394        }
395
396        // 等待服务器连接(带超时)
397        debug!("等待服务器连接到数据端口: {}", data_port);
398        let (data_stream, _addr) = timeout(self.connect_timeout, listener.accept())
399            .await
400            .map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
401            .map_err(|e| Aria2Error::Network(format!("接受数据连接失败: {}", e)))?;
402
403        debug!("主动模式数据连接建立成功");
404        Ok(data_stream)
405    }
406
407    /// 列出目录内容
408    ///
409    /// 支持两种格式:
410    /// - MLSD(机器可读列表,如果服务器支持)
411    /// - LIST(传统 Unix/Windows 格式)
412    ///
413    /// # 参数
414    ///
415    /// - `path`: 要列出的目录路径
416    ///
417    /// # 返回
418    ///
419    /// 返回文件信息向量
420    ///
421    /// # 错误
422    ///
423    /// - 550 目录不存在或不可访问
424    /// - 425/426 数据连接错误
425    pub async fn list_directory(&mut self, path: &str) -> Result<Vec<FtpFileInfo>> {
426        debug!("列出目录: {}", path);
427
428        // 根据当前模式建立数据连接
429        let mut data_stream = match self.mode {
430            FtpMode::Passive => {
431                // 被动模式优先,失败后 fallback 到主动模式
432                match self.passive_mode().await {
433                    Ok(stream) => stream,
434                    Err(e) => {
435                        warn!("被动模式失败,尝试主动模式: {}", e);
436                        self.active_mode().await?
437                    }
438                }
439            }
440            FtpMode::Active => self.active_mode().await?,
441        };
442
443        // 先尝试 MLSD(机器可读格式)
444        self.send_command(&format!("MLSD {}", path)).await?;
445        let resp = self.read_response().await?;
446
447        let use_mlsd = resp.is_positive_preliminary();
448
449        if !use_mlsd {
450            // MLSD 不可用,使用 LIST
451            self.send_command(&format!("LIST {}", path)).await?;
452            let list_resp = self.read_response().await?;
453
454            if !list_resp.is_positive_preliminary() {
455                if list_resp.code == 550 {
456                    return Err(Aria2Error::Recoverable(
457                        crate::error::RecoverableError::ServerError { code: 550 },
458                    ));
459                }
460                return Err(Aria2Error::DownloadFailed(format!(
461                    "LIST 命令失败: {} {}",
462                    list_resp.code, list_resp.message
463                )));
464            }
465        }
466
467        // 读取数据流
468        let mut buffer = String::new();
469        use tokio::io::AsyncReadExt;
470        let bytes_read = timeout(self.read_timeout, data_stream.read_to_string(&mut buffer))
471            .await
472            .map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
473            .map_err(|e| Aria2Error::Io(format!("读取目录列表失败: {}", e)))?;
474
475        drop(data_stream); // 关闭数据连接
476
477        debug!("读取到 {} 字节的目录列表", bytes_read);
478
479        // 读取最终响应
480        let final_resp = self.read_response().await?;
481        if final_resp.code == 426 {
482            return Err(Aria2Error::Recoverable(
483                crate::error::RecoverableError::ServerError { code: 426 },
484            ));
485        } else if !final_resp.is_positive_completion() {
486            return Err(Aria2Error::DownloadFailed(format!(
487                "目录列表传输完成但返回错误: {} {}",
488                final_resp.code, final_resp.message
489            )));
490        }
491
492        // 解析目录列表
493        let files: Vec<FtpFileInfo> = buffer
494            .lines()
495            .filter_map(|line| {
496                let line = line.trim();
497                if line.is_empty() || line.starts_with("total:") {
498                    return None;
499                }
500                Self::parse_list_line(line)
501            })
502            .collect();
503
504        debug!("解析到 {} 个文件/目录条目", files.len());
505        Ok(files)
506    }
507
508    /// 下载文件
509    ///
510    /// 支持断点续传,通过 REST 命令指定偏移量。
511    ///
512    /// # 参数
513    ///
514    /// - `remote_path`: 远程文件路径
515    /// - `offset`: 可选的起始偏移量(用于断点续传)
516    ///
517    /// # 返回
518    ///
519    /// 返回数据连接的 TcpStream,用于读取文件内容
520    ///
521    /// # 错误
522    ///
523    /// - 550 文件不存在
524    /// - 425/426 数据连接错误
525    pub async fn download_file(
526        &mut self,
527        remote_path: &str,
528        offset: Option<u64>,
529    ) -> Result<TcpStream> {
530        debug!("准备下载文件: {} (offset: {:?})", remote_path, offset);
531
532        // 如果有偏移量,先发送 REST 命令
533        if let Some(off) = offset
534            && off > 0
535        {
536            debug!("设置恢复偏移: {}", off);
537            self.send_command(&format!("REST {}", off)).await?;
538            let rest_resp = self.read_response().await?;
539
540            if rest_resp.code != 350 {
541                return Err(Aria2Error::DownloadFailed(format!(
542                    "REST 命令失败(服务器可能不支持断点续传): {} {}",
543                    rest_resp.code, rest_resp.message
544                )));
545            }
546        }
547
548        // 建立数据连接
549        let _data_stream = match self.mode {
550            FtpMode::Passive => match self.passive_mode().await {
551                Ok(stream) => stream,
552                Err(e) => {
553                    warn!("被动模式失败,尝试主动模式: {}", e);
554                    self.active_mode().await?
555                }
556            },
557            FtpMode::Active => self.active_mode().await?,
558        };
559
560        // 发送 RETR 命令
561        self.send_command(&format!("RETR {}", remote_path)).await?;
562        let retr_resp = self.read_response().await?;
563
564        if !retr_resp.is_positive_preliminary() {
565            if retr_resp.code == 550 {
566                return Err(Aria2Error::Recoverable(
567                    crate::error::RecoverableError::ServerError { code: 550 },
568                ));
569            }
570            return Err(Aria2Error::DownloadFailed(format!(
571                "RETR 命令失败: {} {}",
572                retr_resp.code, retr_resp.message
573            )));
574        }
575
576        // 注意:实际的数据流需要在调用方管理
577        // 这里我们返回一个占位符,真实场景中应该返回数据连接
578        // 由于 Rust 的所有权规则,我们需要重新设计这部分
579        // 为了简化,这里创建一个新的连接说明
580        Err(Aria2Error::DownloadFailed(
581            "download_file 需要在数据连接建立后返回流,请使用更高级的 API".to_string(),
582        ))
583    }
584
585    /// 更改工作目录
586    ///
587    /// # 参数
588    ///
589    /// - `path`: 目标目录路径
590    ///
591    /// # 错误
592    ///
593    /// - 550 目录不存在或无权限
594    pub async fn cwd(&mut self, path: &str) -> Result<()> {
595        debug!("更改工作目录: {}", path);
596        self.send_command(&format!("CWD {}", path)).await?;
597        let resp = self.read_response().await?;
598
599        if resp.is_positive_completion() {
600            Ok(())
601        } else if resp.code == 550 {
602            Err(Aria2Error::Recoverable(
603                crate::error::RecoverableError::ServerError { code: 550 },
604            ))
605        } else {
606            Err(Aria2Error::DownloadFailed(format!(
607                "CWD 命令失败: {} {}",
608                resp.code, resp.message
609            )))
610        }
611    }
612
613    /// 获取当前工作目录
614    ///
615    /// # 返回
616    ///
617    /// 返回当前目录路径字符串
618    ///
619    /// # 错误
620    ///
621    /// - 500/501/502 命令执行错误
622    pub async fn pwd(&mut self) -> Result<String> {
623        debug!("查询当前工作目录");
624        self.send_command("PWD").await?;
625        let resp = self.read_response().await?;
626
627        if resp.code == 257 {
628            // PWD 响应格式: "/path/to/dir" 是当前目录
629            // 通常格式为: 257 "/path" is current directory
630            let msg = resp.message.trim();
631            // 提取引号内的路径
632            if let Some(start) = msg.find('"')
633                && let Some(end) = msg.rfind('"')
634                && end > start
635            {
636                let dir = &msg[start + 1..end];
637                debug!("当前目录: {}", dir);
638                return Ok(dir.to_string());
639            }
640            Ok(msg.to_string())
641        } else {
642            Err(Aria2Error::DownloadFailed(format!(
643                "PWD 命令失败: {} {}",
644                resp.code, resp.message
645            )))
646        }
647    }
648
649    /// 中止正在进行的传输
650    ///
651    /// 发送 ABOR 命令,中断当前的数据传输操作。
652    /// 注意:ABOR 的行为在不同服务器上可能不同。
653    ///
654    /// # 错误
655    ///
656    /// - 网络错误(如果控制连接已断开)
657    pub async fn abort(&mut self) -> Result<()> {
658        debug!("发送 ABOR 命令中止传输");
659
660        // ABOR 命令比较特殊,需要特殊处理
661        // 某些实现需要先发送 Telnet IP (Interrupt Process) + SYNCH
662        // 这里简化处理,直接发送命令
663        self.send_command("ABOR").await?;
664
665        // 读取响应(可能有多个响应:426 + 226 或 225 + 226 等)
666        match self.read_response().await {
667            Ok(resp) => {
668                debug!("ABOR 响应: {} {}", resp.code, resp.message);
669
670                // 可能还有第二个响应
671                // 尝试读取但不强制要求
672                let mut buf = String::new();
673                match timeout(
674                    Duration::from_secs(2),
675                    self.control_stream.read_line(&mut buf),
676                )
677                .await
678                {
679                    Ok(Ok(n)) if n > 0 => {
680                        debug!("ABOR 第二个响应: {}", buf.trim());
681                    }
682                    _ => {}
683                }
684
685                Ok(())
686            }
687            Err(e) => {
688                // ABOR 后连接可能处于不一致状态,这是预期的
689                warn!("ABOR 命令后连接状态异常(可能是正常的): {}", e);
690                Ok(())
691            }
692        }
693    }
694
695    /// 断开 FTP 连接
696    ///
697    /// 发送 QUIT 命令并优雅地关闭控制连接。
698    pub async fn quit(mut self) -> Result<()> {
699        debug!("发送 QUIT 命令");
700
701        if let Err(e) = self.send_command("QUIT").await {
702            warn!("发送 QUIT 命令失败(连接可能已关闭): {}", e);
703            return Ok(());
704        }
705
706        match self.read_response().await {
707            Ok(resp) => {
708                info!("FTP 断开连接: {}", resp.message.trim());
709                Ok(())
710            }
711            Err(e) => {
712                warn!("读取 QUIT 响应失败: {}", e);
713                Ok(())
714            }
715        }
716    }
717
718    // ==================== 内部辅助方法 ====================
719
720    /// 发送 FTP 命令
721    ///
722    /// 将命令以 \r\n 结尾写入控制连接。
723    async fn send_command(&mut self, cmd: &str) -> Result<()> {
724        debug!("FTP 命令: {}", cmd.trim());
725
726        self.control_stream
727            .write_all(cmd.as_bytes())
728            .await
729            .map_err(|e| Aria2Error::Network(format!("发送 FTP 命令失败: {}", e)))?;
730
731        self.control_stream
732            .write_all(b"\r\n")
733            .await
734            .map_err(|e| Aria2Error::Network(format!("发送换行符失败: {}", e)))?;
735
736        self.control_stream
737            .flush()
738            .await
739            .map_err(|e| Aria2Error::Network(format!("刷新缓冲区失败: {}", e)))?;
740
741        Ok(())
742    }
743
744    /// 读取 FTP 响应
745    ///
746    /// 处理多行响应,支持标准 FTP 响应格式:
747    /// - 单行: `NNN text`
748    /// - 多行: `NNN-text\n...\nNNN text`
749    async fn read_response(&mut self) -> Result<FtpResponse> {
750        let mut line = String::new();
751        let mut code: Option<u16> = None;
752        let mut message = String::new();
753        let mut is_multiline = false;
754
755        loop {
756            line.clear();
757
758            let bytes_read = timeout(self.read_timeout, self.control_stream.read_line(&mut line))
759                .await
760                .map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
761                .map_err(|e| Aria2Error::Network(format!("读取 FTP 响应失败: {}", e)))?;
762
763            if bytes_read == 0 {
764                break; // 连接关闭
765            }
766
767            let trimmed = line.trim_end();
768            if trimmed.len() < 4 {
769                continue;
770            }
771
772            // 解析 3 位响应码
773            let response_code: u16 = trimmed[..3].parse().unwrap_or(0);
774
775            if code.is_none() {
776                code = Some(response_code);
777            }
778
779            // 判断分隔符
780            let separator = trimmed.as_bytes()[3];
781
782            if separator == b'-' && !is_multiline {
783                // 多行响应开始
784                is_multiline = true;
785                message.push_str(&trimmed[4..]);
786                message.push('\n');
787            } else if separator == b' ' {
788                // 单行响应或多行结束
789                message.push_str(&trimmed[4..]);
790                break;
791            } else if is_multiline && trimmed.starts_with(&format!("{:3} ", code.unwrap_or(0))) {
792                // 多行响应结束标记
793                message.push_str(&trimmed[4..]);
794                break;
795            } else if is_multiline {
796                // 多行中间行
797                message.push_str(&trimmed[4..]);
798                message.push('\n');
799            }
800        }
801
802        let code_val = code.unwrap_or(0);
803        debug!("FTP 响应: {} {}", code_val, message.trim());
804
805        Ok(FtpResponse {
806            code: code_val,
807            message,
808        })
809    }
810
811    /// 解析 PASV 响应,提取 IP 地址和端口
812    ///
813    /// PASV 响应格式: `227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)`
814    ///
815    /// # 参数
816    ///
817    /// - `text`: PASV 响应的消息部分
818    ///
819    /// # 返回
820    ///
821    /// 返回 `(host, port)` 元组
822    fn parse_pasv_response(text: &str) -> Result<(String, u16)> {
823        let start = text
824            .find('(')
825            .ok_or_else(|| Aria2Error::Parse("PASV 响应缺少左括号".to_string()))?;
826
827        let end = text
828            .find(')')
829            .ok_or_else(|| Aria2Error::Parse("PASV 响应缺少右括号".to_string()))?;
830
831        let inner = &text[start + 1..end];
832        let parts: Vec<&str> = inner.split(',').collect();
833
834        if parts.len() != 6 {
835            return Err(Aria2Error::Parse(format!(
836                "PASV 响应格式错误: 期望 6 个部分,得到 {} 个",
837                parts.len()
838            )));
839        }
840
841        let h1: u8 = parts[0]
842            .trim()
843            .parse()
844            .map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h1".to_string()))?;
845        let h2: u8 = parts[1]
846            .trim()
847            .parse()
848            .map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h2".to_string()))?;
849        let h3: u8 = parts[2]
850            .trim()
851            .parse()
852            .map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h3".to_string()))?;
853        let h4: u8 = parts[3]
854            .trim()
855            .parse()
856            .map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h4".to_string()))?;
857        let p1: u16 = parts[4]
858            .trim()
859            .parse()
860            .map_err(|_| Aria2Error::Parse("PASV 响应: 无效的端口字节 p1".to_string()))?;
861        let p2: u16 = parts[5]
862            .trim()
863            .parse()
864            .map_err(|_| Aria2Error::Parse("PASV 响应: 无效的端口字节 p2".to_string()))?;
865
866        let host = format!("{}.{}.{}.{}", h1, h2, h3, h4);
867        let port = p1 * 256 + p2;
868
869        Ok((host, port))
870    }
871
872    /// 解析 EPSV 响应,提取端口号
873    ///
874    /// EPSV 响应格式: `229 Entering Extended Passive Mode (|||port|)`
875    ///
876    /// # 参数
877    ///
878    /// - `text`: EPSV 响应的消息部分
879    ///
880    /// # 返回
881    ///
882    /// 返回端口号,如果解析失败返回 None
883    fn parse_epsv_response(text: &str) -> Option<u16> {
884        let start = text.rfind('|')?;
885        let prev_pipe = text[..start].rfind('|')?;
886        let port_str = &text[prev_pipe + 1..start];
887        port_str.parse::<u16>().ok()
888    }
889
890    /// 解析 LIST 输出的单行
891    ///
892    /// 支持 Unix 格式 (`-rw-r--r--  1 user group   size date  name`) 和
893    /// Windows 格式 (`date       size  name` 或 `dir`)。
894    ///
895    /// # 参数
896    ///
897    /// - `line`: LIST 输出的单行文本
898    ///
899    /// # 返回
900    ///
901    /// 返回解析后的文件信息,如果无法解析返回 None
902    pub(crate) fn parse_list_line(line: &str) -> Option<FtpFileInfo> {
903        let trimmed = line.trim();
904        if trimmed.is_empty() {
905            return None;
906        }
907
908        // 尝试 Unix 格式解析
909        if let Some(info) = Self::parse_unix_list_line(trimmed) {
910            return Some(info);
911        }
912
913        // 尝试 Windows 格式解析
914        if let Some(info) = Self::parse_windows_list_line(trimmed) {
915            return Some(info);
916        }
917
918        // 尝试 MLSD 格式解析
919        if let Some(info) = Self::parse_mlsd_line(trimmed) {
920            return Some(info);
921        }
922
923        None
924    }
925
926    /// Parse Unix ls -l format using fast path (zero-dependency string parsing)
927    ///
928    /// This fast path handles ~90% of real-world FTP LIST responses which use
929    /// standard Unix ls -l format, avoiding regex compilation and matching overhead.
930    ///
931    /// Format: `[type][perms] [links] [owner] [group] [size] [mon] [day] [time/year] [name]`
932    /// Example: `-rw-r--r--  1 user staff  12345 Jan 15 10:30 document.pdf`
933    ///
934    /// # Returns
935    ///
936    /// `Some(FtpFileInfo)` if parsing succeeds, `None` if line doesn't match expected format
937    fn parse_list_line_fast(line: &str) -> Option<FtpFileInfo> {
938        // Minimum viable line length check:
939        // type(1) + perms(9) + spaces(3+) + links(1+) + owner(1+) + spaces + group(1+)
940        // + spaces + size(1+) + spaces + month(3) + spaces + day(1+) + spaces + time(4-5/year)
941        // + space + name(1+) >= ~40 chars for realistic entries
942        if line.len() < 35 {
943            return None;
944        }
945
946        // Determine entry type from first character
947        let entry_type = match line.as_bytes().first()? {
948            b'd' => true,  // Directory
949            b'-' => false, // Regular file
950            b'l' => {
951                // Symlink - handle specially below
952                // For symlinks, we'll parse but mark as non-directory
953                false
954            }
955            _ => return None, // Unknown type, fallback to regex
956        };
957
958        let is_dir = entry_type;
959
960        // Validate permission field (chars 1-9 should be [rwxst-])
961        let perms = &line[1..10];
962        if !perms.chars().all(|c| "rwxst-".contains(c)) {
963            return None;
964        }
965
966        // Skip permission field and split rest by whitespace
967        let after_perms = line[10..].trim_start();
968
969        // Find positions of each field by scanning for whitespace
970        // Expected fields: links owner group size month day time/year name
971        // We need to skip 7 fields and capture the rest as filename
972        let mut pos = 0;
973        for _ in 0..7 {
974            // Skip current field (non-whitespace)
975            let end = after_perms[pos..]
976                .find(' ')
977                .unwrap_or(after_perms.len() - pos);
978            pos += end + 1;
979            // Skip whitespace between fields
980            while pos < after_perms.len() && after_perms.as_bytes()[pos] == b' ' {
981                pos += 1;
982            }
983            if pos >= after_perms.len() {
984                return None;
985            }
986        }
987
988        // Remaining part is the filename (may contain spaces)
989        let name_raw = after_perms[pos..].trim();
990        if name_raw.is_empty() {
991            return None;
992        }
993
994        // Handle symlink format: "linkname -> target"
995        let actual_name = if line.as_bytes()[0] == b'l' {
996            if let Some(arrow_pos) = name_raw.find(" -> ") {
997                &name_raw[..arrow_pos]
998            } else {
999                name_raw
1000            }
1001        } else {
1002            name_raw
1003        };
1004
1005        // Filter out special entries
1006        if actual_name == "." || actual_name == ".." {
1007            return None;
1008        }
1009
1010        // Parse size from the line (field index 3, 0-based)
1011        // Fields after permissions: links(0) owner(1) group(2) size(3) month(4) ...
1012        let size_field = after_perms.split_whitespace().nth(3)?;
1013        let size: u64 = size_field.parse().ok()?;
1014
1015        Some(FtpFileInfo {
1016            name: actual_name.to_string(),
1017            size,
1018            is_dir,
1019        })
1020    }
1021
1022    /// Parse Unix-format LIST line with fast path optimization
1023    ///
1024    /// Tries zero-allocation string parsing first (~90% of cases),
1025    /// falls back to regex for exotic formats.
1026    fn parse_unix_list_line(line: &str) -> Option<FtpFileInfo> {
1027        // Fast path for standard Unix ls -l format (avoids regex overhead)
1028        if let Some(info) = Self::parse_list_line_fast(line) {
1029            return Some(info);
1030        }
1031
1032        // Fallback to regex for exotic/non-standard formats
1033        Self::parse_unix_list_line_regex(line)
1034    }
1035
1036    /// Parse Unix ls -l format using regex (fallback for non-standard formats)
1037    ///
1038    /// Unix format example:
1039    /// ```text
1040    /// -rw-r--r--  1 user group  12345 Jan 15 10:30 filename.txt
1041    /// drwxr-xr-x  2 user group   4096 Feb  3 14:20 directory
1042    /// lrwxrwxrwx  1 user group     8 Mar 10 09:00 link -> target
1043    /// ```
1044    fn parse_unix_list_line_regex(line: &str) -> Option<FtpFileInfo> {
1045        // Use regex to match Unix ls -l format
1046        // Format: [type][perms]  [links] [user] [group] [size] [mon] [day] [time/year] [name]
1047        // Example: -rw-r--r--  1 user staff  12345 Jan 15 10:30 document.pdf
1048
1049        // Regex pattern explanation:
1050        // ^([bcdlsp-])           # File type (1 char)
1051        // ([rwxst-]{9})          # Permission bits (9 chars)
1052        // \s+                     # One or more spaces
1053        // (\d+)                   # Hard link count
1054        // \s+                     # Space
1055        // (\S+)                   # Username
1056        // \s+                     # Space
1057        // (\S+)                   # Group name
1058        // \s+                     # Space
1059        // (\d+)                   # File size
1060        // \s+                     # Space
1061        // (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)  # Month
1062        // \s+                     # Space
1063        // (\d{1,2})              # Day (1-2 digits)
1064        // \s+                     # Space
1065        // (\d{4}|\d{1,2}:\d{2})  # Year (4 digits) or time (HH:MM)
1066        // \s+                     # Space
1067        // (.+)$                  # Filename (may contain spaces)
1068
1069        use regex::Regex;
1070
1071        let re = Regex::new(
1072            r"^([bcdlsp-])([rwxst-]{9})\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})\s+(\d{4}|\d{1,2}:\d{2})\s+(.+)$"
1073        ).ok()?;
1074
1075        let caps = re.captures(line)?;
1076
1077        let type_char = caps.get(1)?.as_str().chars().next()?;
1078        let is_dir = type_char == 'd';
1079        let is_link = type_char == 'l';
1080
1081        let size: u64 = caps.get(6)?.as_str().parse().ok()?;
1082        let name = caps.get(10)?.as_str();
1083
1084        if name.is_empty() {
1085            return None;
1086        }
1087
1088        // Handle symlink: "link -> target"
1089        let actual_name = if is_link {
1090            if let Some(arrow_pos) = name.find(" -> ") {
1091                &name[..arrow_pos]
1092            } else {
1093                name
1094            }
1095        } else {
1096            name
1097        };
1098
1099        // Special entries: "." and ".."
1100        if actual_name == "." || actual_name == ".." {
1101            return None;
1102        }
1103
1104        Some(FtpFileInfo {
1105            name: actual_name.to_string(),
1106            size,
1107            is_dir,
1108        })
1109    }
1110
1111    /// 解析 Windows/DOS 格式的 LIST 行
1112    ///
1113    /// Windows 格式示例:
1114    /// ```text
1115    /// 01-15-24  10:30AM    12345 filename.txt
1116    /// 02-03-24  02:20PM    <DIR> directory
1117    /// ```
1118    fn parse_windows_list_line(line: &str) -> Option<FtpFileInfo> {
1119        // Windows 格式: "MM-DD-YY  HH:MM[AP]M  <DIR>/size  name"
1120        // 最小长度检查
1121        if line.len() < 20 {
1122            return None;
1123        }
1124
1125        // 日期部分: MM-DD-YY (8 字符)
1126        let date_part = &line[..8];
1127        if date_part.len() != 8
1128            || date_part.chars().nth(2)? != '-'
1129            || date_part.chars().nth(5)? != '-'
1130        {
1131            return None;
1132        }
1133
1134        let after_date = line[8..].trim_start();
1135
1136        // 时间部分: HH:MM[AP]M (7-9 字符)
1137        let space_pos = after_date.find(' ')?;
1138        let time_part = &after_date[..space_pos];
1139        if !time_part.contains(':') {
1140            return None;
1141        }
1142
1143        let after_time = after_date[space_pos + 1..].trim_start();
1144
1145        // 大小或 <DIR>
1146        let space_pos = after_time.find(' ')?;
1147        let size_or_dir = after_time[..space_pos].trim();
1148
1149        let is_dir = size_or_dir.eq_ignore_ascii_case("<DIR>");
1150        let size: u64 = if is_dir { 0 } else { size_or_dir.parse().ok()? };
1151
1152        // 文件名
1153        let name = after_time[space_pos + 1..].trim().to_string();
1154
1155        if name.is_empty() || name == "." || name == ".." {
1156            return None;
1157        }
1158
1159        Some(FtpFileInfo { name, size, is_dir })
1160    }
1161
1162    /// 解析 MLSD (Machine Listing) 格式的行
1163    ///
1164    /// MLSD 格式示例:
1165    /// ```text
1166    /// type=file;size=12345;modify=20240115103000;unix.mode=0644; filename.txt
1167    /// type=dir;size=4096;modify=20240203142000;unix.mode=0755; directory
1168    /// type=os.unix=symlink=/target;size=8; link
1169    /// ```
1170    fn parse_mlsd_line(line: &str) -> Option<FtpFileInfo> {
1171        // MLSD 格式: facts; facts; ... name
1172        // facts 和 name之间用空格分隔
1173        let semicolon_pos = line.rfind("; ")?;
1174        let (facts_str, name) = line.split_at(semicolon_pos + 2);
1175        let name = name.trim();
1176
1177        if name.is_empty() || name == "." || name == ".." {
1178            return None;
1179        }
1180
1181        // 解析事实(facts)
1182        let mut is_dir = false;
1183        let mut size: u64 = 0;
1184
1185        for fact in facts_str.split(';') {
1186            let fact = fact.trim();
1187            if fact.is_empty() {
1188                continue;
1189            }
1190
1191            if let Some(eq_pos) = fact.find('=') {
1192                let key = &fact[..eq_pos];
1193                let value = &fact[eq_pos + 1..];
1194
1195                match key.to_lowercase().as_str() {
1196                    "type" => {
1197                        is_dir = value.eq_ignore_ascii_case("dir")
1198                            || value.eq_ignore_ascii_case("cdir")
1199                            || value.eq_ignore_ascii_case("pdir");
1200                    }
1201                    "size" => {
1202                        size = value.parse().unwrap_or(0);
1203                    }
1204                    _ => {}
1205                }
1206            }
1207        }
1208
1209        Some(FtpFileInfo {
1210            name: name.to_string(),
1211            size,
1212            is_dir,
1213        })
1214    }
1215}
1216
1217// ==================== 测试模块 ====================
1218#[cfg(test)]
1219mod tests {
1220    use super::*;
1221
1222    #[test]
1223    fn test_ftp_response_checks() {
1224        // 测试正向完成响应 (2xx)
1225        let ok = FtpResponse {
1226            code: 226,
1227            message: "Transfer complete".into(),
1228        };
1229        assert!(ok.is_success());
1230        assert!(ok.is_positive_completion());
1231        assert!(!ok.is_positive_preliminary());
1232
1233        // 测试正向预备响应 (1xx)
1234        let preliminary = FtpResponse {
1235            code: 150,
1236            message: "Opening data connection".into(),
1237        };
1238        assert!(preliminary.is_success());
1239        assert!(!preliminary.is_positive_completion());
1240        assert!(preliminary.is_positive_preliminary());
1241
1242        // 测试错误响应 (4xx/5xx)
1243        let error = FtpResponse {
1244            code: 550,
1245            message: "File not found".into(),
1246        };
1247        assert!(!error.is_success());
1248        assert!(!error.is_positive_completion());
1249        assert!(!error.is_positive_preliminary());
1250    }
1251
1252    #[test]
1253    fn test_parse_pasv_response_valid() {
1254        let msg = "Entering Passive Mode (192,168,1,100,195,123)";
1255        let result = FtpClient::parse_pasv_response(msg);
1256        assert!(result.is_ok());
1257        let (host, port) = result.unwrap();
1258        assert_eq!(host, "192.168.1.100");
1259        assert_eq!(port, 195 * 256 + 123); // 195*256 + 123 = 50043
1260    }
1261
1262    #[test]
1263    fn test_parse_pasv_response_invalid() {
1264        // 缺少括号
1265        let msg = "Entering Passive Mode 192,168,1,100,195,123";
1266        let result = FtpClient::parse_pasv_response(msg);
1267        assert!(result.is_err());
1268
1269        // 部分数量不对
1270        let msg2 = "Entering Passive Mode (192,168,1,100,195)";
1271        let result2 = FtpClient::parse_pasv_response(msg2);
1272        assert!(result2.is_err());
1273    }
1274
1275    #[test]
1276    fn test_parse_epsv_response_valid() {
1277        let msg = "Entering Extended Passive Mode (|||50001|)";
1278        let result = FtpClient::parse_epsv_response(msg);
1279        assert_eq!(result, Some(50001));
1280    }
1281
1282    #[test]
1283    fn test_parse_epsv_response_invalid() {
1284        let msg = "Invalid EPSV response";
1285        let result = FtpClient::parse_epsv_response(msg);
1286        assert_eq!(result, None);
1287    }
1288
1289    #[test]
1290    fn test_parse_list_line_unix_regular_file() {
1291        let line = "-rw-r--r--  1 user staff  12345 Jan 15 10:30 document.pdf";
1292        let result = FtpClient::parse_list_line(line);
1293        assert!(result.is_some());
1294        let info = result.unwrap();
1295        assert_eq!(info.name, "document.pdf");
1296        assert_eq!(info.size, 12345);
1297        assert!(!info.is_dir);
1298    }
1299
1300    #[test]
1301    fn test_parse_list_line_unix_directory() {
1302        let line = "drwxr-xr-x  2 user staff   4096 Feb  3 14:20 my_folder";
1303        let result = FtpClient::parse_list_line(line);
1304        assert!(result.is_some());
1305        let info = result.unwrap();
1306        assert_eq!(info.name, "my_folder");
1307        assert_eq!(info.size, 4096);
1308        assert!(info.is_dir);
1309    }
1310
1311    #[test]
1312    fn test_parse_list_line_unix_symlink() {
1313        let line = "lrwxrwxrwx  1 user staff      8 Mar 10 09:00 link.txt -> target.txt";
1314        let result = FtpClient::parse_list_line(line);
1315        assert!(result.is_some());
1316        let info = result.unwrap();
1317        assert_eq!(info.name, "link.txt"); // 符号链接应该返回链接名而非目标
1318        assert!(!info.is_dir);
1319    }
1320
1321    #[test]
1322    fn test_parse_list_line_unix_hidden_file() {
1323        let line = "-rw-r--r--  1 user staff    512 Apr  1 08:00 .bashrc";
1324        let result = FtpClient::parse_list_line(line);
1325        assert!(result.is_some());
1326        let info = result.unwrap();
1327        assert_eq!(info.name, ".bashrc");
1328        assert_eq!(info.size, 512);
1329        assert!(!info.is_dir);
1330    }
1331
1332    #[test]
1333    fn test_parse_list_line_unix_special_entries() {
1334        // 应该忽略 "." 和 ".."
1335        let dot = "drwxr-xr-x  2 user staff   4096 Jan  1 00:00 .";
1336        let dotdot = "drwxr-xr-x  2 user staff   4096 Jan  1 00:00 ..";
1337
1338        assert!(FtpClient::parse_list_line(dot).is_none());
1339        assert!(FtpClient::parse_list_line(dotdot).is_none());
1340    }
1341
1342    #[test]
1343    fn test_parse_list_line_windows_file() {
1344        let line = "01-15-24  10:30AM    12345 document.pdf";
1345        let result = FtpClient::parse_list_line(line);
1346        assert!(result.is_some());
1347        let info = result.unwrap();
1348        assert_eq!(info.name, "document.pdf");
1349        assert_eq!(info.size, 12345);
1350        assert!(!info.is_dir);
1351    }
1352
1353    #[test]
1354    fn test_parse_list_line_windows_directory() {
1355        let line = "02-03-24  02:20PM    <DIR> my_folder";
1356        let result = FtpClient::parse_list_line(line);
1357        assert!(result.is_some());
1358        let info = result.unwrap();
1359        assert_eq!(info.name, "my_folder");
1360        assert!(info.is_dir);
1361    }
1362
1363    #[test]
1364    fn test_parse_list_line_mlsd_format() {
1365        let line = "type=file;size=12345;modify=20240115103000;unix.mode=0644; document.pdf";
1366        let result = FtpClient::parse_list_line(line);
1367        assert!(result.is_some());
1368        let info = result.unwrap();
1369        assert_eq!(info.name, "document.pdf");
1370        assert_eq!(info.size, 12345);
1371        assert!(!info.is_dir);
1372    }
1373
1374    #[test]
1375    fn test_parse_list_line_mlsd_directory() {
1376        let line = "type=dir;size=4096;modify=20240203142000;unix.mode=0755; my_folder";
1377        let result = FtpClient::parse_list_line(line);
1378        assert!(result.is_some());
1379        let info = result.unwrap();
1380        assert_eq!(info.name, "my_folder");
1381        assert_eq!(info.size, 4096);
1382        assert!(info.is_dir);
1383    }
1384
1385    #[test]
1386    fn test_ftp_mode_default() {
1387        let mode = FtpMode::default();
1388        assert_eq!(mode, FtpMode::Passive);
1389    }
1390
1391    #[test]
1392    fn test_ftp_file_info_creation() {
1393        let info = FtpFileInfo {
1394            name: "test.txt".to_string(),
1395            size: 1024,
1396            is_dir: false,
1397        };
1398        assert_eq!(info.name, "test.txt");
1399        assert_eq!(info.size, 1024);
1400        assert!(!info.is_dir);
1401    }
1402
1403    #[test]
1404    fn test_parse_list_line_with_spaces_in_name() {
1405        // Unix 格式,文件名包含空格
1406        let line = "-rw-r--r--  1 user staff   5678 Jan 20 11:00 my document with spaces.txt";
1407        let result = FtpClient::parse_list_line(line);
1408        assert!(result.is_some());
1409        let info = result.unwrap();
1410        assert_eq!(info.name, "my document with spaces.txt");
1411        assert_eq!(info.size, 5678);
1412    }
1413
1414    #[test]
1415    fn test_parse_list_line_unrecognized_format() {
1416        // 无法识别的格式
1417        let line = "this is not a valid listing format";
1418        let result = FtpClient::parse_list_line(line);
1419        assert!(result.is_none());
1420    }
1421
1422    #[test]
1423    fn test_parse_pasv_edge_cases() {
1424        // 边界值: 最小端口
1425        let min_msg = "Entering Passive Mode (127,0,0,1,0,0)";
1426        let min_result = FtpClient::parse_pasv_response(min_msg).unwrap();
1427        assert_eq!(min_result.1, 0);
1428
1429        // 边界值: 最大端口
1430        let max_msg = "Entering Passive Mode (255,255,255,255,255,255)";
1431        let max_result = FtpClient::parse_pasv_response(max_msg).unwrap();
1432        assert_eq!(max_result.1, 255 * 256 + 255); // 65535
1433    }
1434}