1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum FtpMode {
14 #[default]
16 Passive,
17 Active,
19}
20
21#[derive(Debug, Clone)]
23pub struct FtpResponse {
24 pub code: u16,
26 pub message: String,
28}
29
30impl FtpResponse {
31 pub fn is_success(&self) -> bool {
33 (100..400).contains(&self.code)
34 }
35
36 pub fn is_intermediate(&self) -> bool {
38 (100..200).contains(&self.code)
39 }
40
41 pub fn is_positive_completion(&self) -> bool {
43 (200..300).contains(&self.code)
44 }
45
46 pub fn is_positive_preliminary(&self) -> bool {
48 (100..200).contains(&self.code)
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct FtpFileInfo {
55 pub name: String,
57 pub size: u64,
59 pub is_dir: bool,
61}
62
63pub struct FtpClient {
92 pub(crate) control_stream: BufReader<TcpStream>,
94 pub(crate) mode: FtpMode,
96 pub(crate) binary_mode: bool,
98 pub(crate) host: String,
100 #[allow(dead_code)] pub(crate) port: u16,
103 pub(crate) connect_timeout: Duration,
105 pub(crate) read_timeout: Duration,
107}
108
109impl FtpClient {
110 const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
112 const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
114
115 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 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 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 info!("FTP 登录成功 (无需密码)");
181 Ok(())
182 }
183 331 | 332 => {
184 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 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 pub async fn passive_mode(&mut self) -> Result<TcpStream> {
268 debug!("请求被动模式数据连接");
269
270 self.send_command("EPSV").await?;
272 let resp = self.read_response().await?;
273
274 if resp.code == 229 {
275 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 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 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 pub async fn active_mode(&mut self) -> Result<TcpStream> {
328 debug!("请求主动模式数据连接");
329
330 let local_addr = self
332 .control_stream
333 .get_ref()
334 .local_addr()
335 .map_err(|e| Aria2Error::Network(format!("获取本地地址失败: {}", e)))?;
336
337 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 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 if !resp.is_positive_completion() {
363 warn!("EPRT 不可用,回退到 PORT 模式");
364
365 let ipv4_addr = match local_ip {
369 std::net::IpAddr::V4(v4) => v4,
370 std::net::IpAddr::V6(_) => {
371 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 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 pub async fn list_directory(&mut self, path: &str) -> Result<Vec<FtpFileInfo>> {
426 debug!("列出目录: {}", path);
427
428 let mut data_stream = match self.mode {
430 FtpMode::Passive => {
431 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 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 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 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); debug!("读取到 {} 字节的目录列表", bytes_read);
478
479 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 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 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 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 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 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 Err(Aria2Error::DownloadFailed(
581 "download_file 需要在数据连接建立后返回流,请使用更高级的 API".to_string(),
582 ))
583 }
584
585 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 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 let msg = resp.message.trim();
631 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 pub async fn abort(&mut self) -> Result<()> {
658 debug!("发送 ABOR 命令中止传输");
659
660 self.send_command("ABOR").await?;
664
665 match self.read_response().await {
667 Ok(resp) => {
668 debug!("ABOR 响应: {} {}", resp.code, resp.message);
669
670 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 warn!("ABOR 命令后连接状态异常(可能是正常的): {}", e);
690 Ok(())
691 }
692 }
693 }
694
695 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 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 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; }
766
767 let trimmed = line.trim_end();
768 if trimmed.len() < 4 {
769 continue;
770 }
771
772 let response_code: u16 = trimmed[..3].parse().unwrap_or(0);
774
775 if code.is_none() {
776 code = Some(response_code);
777 }
778
779 let separator = trimmed.as_bytes()[3];
781
782 if separator == b'-' && !is_multiline {
783 is_multiline = true;
785 message.push_str(&trimmed[4..]);
786 message.push('\n');
787 } else if separator == b' ' {
788 message.push_str(&trimmed[4..]);
790 break;
791 } else if is_multiline && trimmed.starts_with(&format!("{:3} ", code.unwrap_or(0))) {
792 message.push_str(&trimmed[4..]);
794 break;
795 } else if is_multiline {
796 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 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 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 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 if let Some(info) = Self::parse_unix_list_line(trimmed) {
910 return Some(info);
911 }
912
913 if let Some(info) = Self::parse_windows_list_line(trimmed) {
915 return Some(info);
916 }
917
918 if let Some(info) = Self::parse_mlsd_line(trimmed) {
920 return Some(info);
921 }
922
923 None
924 }
925
926 fn parse_list_line_fast(line: &str) -> Option<FtpFileInfo> {
938 if line.len() < 35 {
943 return None;
944 }
945
946 let entry_type = match line.as_bytes().first()? {
948 b'd' => true, b'-' => false, b'l' => {
951 false
954 }
955 _ => return None, };
957
958 let is_dir = entry_type;
959
960 let perms = &line[1..10];
962 if !perms.chars().all(|c| "rwxst-".contains(c)) {
963 return None;
964 }
965
966 let after_perms = line[10..].trim_start();
968
969 let mut pos = 0;
973 for _ in 0..7 {
974 let end = after_perms[pos..]
976 .find(' ')
977 .unwrap_or(after_perms.len() - pos);
978 pos += end + 1;
979 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 let name_raw = after_perms[pos..].trim();
990 if name_raw.is_empty() {
991 return None;
992 }
993
994 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 if actual_name == "." || actual_name == ".." {
1007 return None;
1008 }
1009
1010 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 fn parse_unix_list_line(line: &str) -> Option<FtpFileInfo> {
1027 if let Some(info) = Self::parse_list_line_fast(line) {
1029 return Some(info);
1030 }
1031
1032 Self::parse_unix_list_line_regex(line)
1034 }
1035
1036 fn parse_unix_list_line_regex(line: &str) -> Option<FtpFileInfo> {
1045 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 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 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 fn parse_windows_list_line(line: &str) -> Option<FtpFileInfo> {
1119 if line.len() < 20 {
1122 return None;
1123 }
1124
1125 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 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 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 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 fn parse_mlsd_line(line: &str) -> Option<FtpFileInfo> {
1171 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 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#[cfg(test)]
1219mod tests {
1220 use super::*;
1221
1222 #[test]
1223 fn test_ftp_response_checks() {
1224 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 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 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); }
1261
1262 #[test]
1263 fn test_parse_pasv_response_invalid() {
1264 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 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"); 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 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 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 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 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 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); }
1434}