use crate::error::{Aria2Error, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::{Duration, timeout};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FtpMode {
#[default]
Passive,
Active,
}
#[derive(Debug, Clone)]
pub struct FtpResponse {
pub code: u16,
pub message: String,
}
impl FtpResponse {
pub fn is_success(&self) -> bool {
(100..400).contains(&self.code)
}
pub fn is_intermediate(&self) -> bool {
(100..200).contains(&self.code)
}
pub fn is_positive_completion(&self) -> bool {
(200..300).contains(&self.code)
}
pub fn is_positive_preliminary(&self) -> bool {
(100..200).contains(&self.code)
}
}
#[derive(Debug, Clone)]
pub struct FtpFileInfo {
pub name: String,
pub size: u64,
pub is_dir: bool,
}
pub struct FtpClient {
pub(crate) control_stream: BufReader<TcpStream>,
pub(crate) mode: FtpMode,
pub(crate) binary_mode: bool,
pub(crate) host: String,
#[allow(dead_code)] pub(crate) port: u16,
pub(crate) connect_timeout: Duration,
pub(crate) read_timeout: Duration,
}
impl FtpClient {
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
pub async fn connect(host: &str, port: u16, mode: FtpMode) -> Result<Self> {
info!("FTP 连接中: {}:{}", host, port);
let stream = timeout(
Self::DEFAULT_CONNECT_TIMEOUT,
TcpStream::connect((host, port)),
)
.await
.map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
.map_err(|e| Aria2Error::Network(format!("FTP 连接失败: {}", e)))?;
let mut client = Self {
control_stream: BufReader::new(stream),
mode,
binary_mode: false,
host: host.to_string(),
port,
connect_timeout: Self::DEFAULT_CONNECT_TIMEOUT,
read_timeout: Self::DEFAULT_READ_TIMEOUT,
};
let welcome = client.read_response().await?;
if !welcome.is_positive_completion() && !welcome.is_positive_preliminary() {
return Err(Aria2Error::DownloadFailed(format!(
"FTP 服务器拒绝连接: {} {}",
welcome.code, welcome.message
)));
}
debug!("FTP 连接成功: {}", welcome.message.trim());
Ok(client)
}
pub async fn login(&mut self, username: &str, password: &str) -> Result<()> {
debug!("发送 USER 命令: {}", username);
self.send_command(&format!("USER {}", username)).await?;
let resp = self.read_response().await?;
match resp.code {
230 => {
info!("FTP 登录成功 (无需密码)");
Ok(())
}
331 | 332 => {
debug!("需要密码认证,发送 PASS 命令");
self.send_command(&format!("PASS {}", password)).await?;
let pass_resp = self.read_response().await?;
if pass_resp.code == 230 || pass_resp.code == 202 {
info!("FTP 登录成功");
Ok(())
} else if pass_resp.code == 530 {
Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 530 },
))
} else {
Err(Aria2Error::DownloadFailed(format!(
"FTP 登录失败: {} {}",
pass_resp.code, pass_resp.message
)))
}
}
530 => Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 530 },
)),
_ => {
if resp.is_positive_completion() {
info!("FTP 登录成功");
Ok(())
} else {
Err(Aria2Error::DownloadFailed(format!(
"FTP 登录失败: {} {}",
resp.code, resp.message
)))
}
}
}
}
pub async fn set_binary_mode(&mut self, enabled: bool) -> Result<()> {
let type_cmd = if enabled { "TYPE I" } else { "TYPE A" };
debug!("设置传输类型: {}", type_cmd);
self.send_command(type_cmd).await?;
let resp = self.read_response().await?;
if resp.is_positive_completion() {
self.binary_mode = enabled;
debug!(
"传输模式设置为: {}",
if enabled { "Binary" } else { "ASCII" }
);
Ok(())
} else if resp.code == 504 {
Err(Aria2Error::DownloadFailed(format!(
"不支持的传输模式: {}",
resp.message
)))
} else {
Err(Aria2Error::DownloadFailed(format!(
"TYPE 命令失败: {} {}",
resp.code, resp.message
)))
}
}
pub async fn passive_mode(&mut self) -> Result<TcpStream> {
debug!("请求被动模式数据连接");
self.send_command("EPSV").await?;
let resp = self.read_response().await?;
if resp.code == 229 {
if let Some(port) = Self::parse_epsv_response(&resp.message) {
debug!("EPSV 数据通道端口: {}", port);
let data_stream = timeout(
self.connect_timeout,
TcpStream::connect((self.host.as_str(), port)),
)
.await
.map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
.map_err(|e| Aria2Error::Network(format!("EPSV 数据连接失败: {}", e)))?;
return Ok(data_stream);
}
}
warn!("EPSV 不可用,回退到 PASV 模式");
self.send_command("PASV").await?;
let pasv_resp = self.read_response().await?;
if pasv_resp.code != 227 {
return Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 425 },
));
}
let (data_host, data_port) = Self::parse_pasv_response(&pasv_resp.message)?;
debug!("PASV 数据通道: {}:{}", data_host, data_port);
let data_stream = timeout(
self.connect_timeout,
TcpStream::connect((data_host.as_str(), data_port)),
)
.await
.map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
.map_err(|e| Aria2Error::Network(format!("PASV 数据连接失败: {}", e)))?;
Ok(data_stream)
}
pub async fn active_mode(&mut self) -> Result<TcpStream> {
debug!("请求主动模式数据连接");
let local_addr = self
.control_stream
.get_ref()
.local_addr()
.map_err(|e| Aria2Error::Network(format!("获取本地地址失败: {}", e)))?;
let listener = tokio::net::TcpListener::bind("0.0.0.0:0")
.await
.map_err(|e| Aria2Error::Network(format!("绑定数据端口失败: {}", e)))?;
let data_port = listener
.local_addr()
.map_err(|e| Aria2Error::Network(format!("获取监听端口失败: {}", e)))?
.port();
let local_ip = local_addr.ip();
let eprt_cmd = format!("EPRT |1|{}|{}|", local_ip, data_port);
debug!("发送 EPRT 命令: {}", eprt_cmd);
self.send_command(&eprt_cmd).await?;
let resp = self.read_response().await?;
if resp.code != 200 && resp.code != 500 && resp.code != 501 && resp.code != 502 {
return Err(Aria2Error::DownloadFailed(format!(
"EPRT 命令失败: {} {}",
resp.code, resp.message
)));
}
if !resp.is_positive_completion() {
warn!("EPRT 不可用,回退到 PORT 模式");
let ipv4_addr = match local_ip {
std::net::IpAddr::V4(v4) => v4,
std::net::IpAddr::V6(_) => {
return Err(Aria2Error::DownloadFailed(
"IPv6 不支持主动模式 PORT 命令,请使用被动模式".to_string(),
));
}
};
let ip_bytes = ipv4_addr.octets();
let p1 = data_port / 256;
let p2 = data_port % 256;
let port_cmd = format!(
"PORT {},{},{},{},{},{}",
ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3], p1, p2
);
debug!("发送 PORT 命令: {}", port_cmd);
self.send_command(&port_cmd).await?;
let port_resp = self.read_response().await?;
if !port_resp.is_positive_completion() {
return Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 425 },
));
}
}
debug!("等待服务器连接到数据端口: {}", data_port);
let (data_stream, _addr) = timeout(self.connect_timeout, listener.accept())
.await
.map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
.map_err(|e| Aria2Error::Network(format!("接受数据连接失败: {}", e)))?;
debug!("主动模式数据连接建立成功");
Ok(data_stream)
}
pub async fn list_directory(&mut self, path: &str) -> Result<Vec<FtpFileInfo>> {
debug!("列出目录: {}", path);
let mut data_stream = match self.mode {
FtpMode::Passive => {
match self.passive_mode().await {
Ok(stream) => stream,
Err(e) => {
warn!("被动模式失败,尝试主动模式: {}", e);
self.active_mode().await?
}
}
}
FtpMode::Active => self.active_mode().await?,
};
self.send_command(&format!("MLSD {}", path)).await?;
let resp = self.read_response().await?;
let use_mlsd = resp.is_positive_preliminary();
if !use_mlsd {
self.send_command(&format!("LIST {}", path)).await?;
let list_resp = self.read_response().await?;
if !list_resp.is_positive_preliminary() {
if list_resp.code == 550 {
return Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 550 },
));
}
return Err(Aria2Error::DownloadFailed(format!(
"LIST 命令失败: {} {}",
list_resp.code, list_resp.message
)));
}
}
let mut buffer = String::new();
use tokio::io::AsyncReadExt;
let bytes_read = timeout(self.read_timeout, data_stream.read_to_string(&mut buffer))
.await
.map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
.map_err(|e| Aria2Error::Io(format!("读取目录列表失败: {}", e)))?;
drop(data_stream);
debug!("读取到 {} 字节的目录列表", bytes_read);
let final_resp = self.read_response().await?;
if final_resp.code == 426 {
return Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 426 },
));
} else if !final_resp.is_positive_completion() {
return Err(Aria2Error::DownloadFailed(format!(
"目录列表传输完成但返回错误: {} {}",
final_resp.code, final_resp.message
)));
}
let files: Vec<FtpFileInfo> = buffer
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with("total:") {
return None;
}
Self::parse_list_line(line)
})
.collect();
debug!("解析到 {} 个文件/目录条目", files.len());
Ok(files)
}
pub async fn download_file(
&mut self,
remote_path: &str,
offset: Option<u64>,
) -> Result<TcpStream> {
debug!("准备下载文件: {} (offset: {:?})", remote_path, offset);
if let Some(off) = offset
&& off > 0
{
debug!("设置恢复偏移: {}", off);
self.send_command(&format!("REST {}", off)).await?;
let rest_resp = self.read_response().await?;
if rest_resp.code != 350 {
return Err(Aria2Error::DownloadFailed(format!(
"REST 命令失败(服务器可能不支持断点续传): {} {}",
rest_resp.code, rest_resp.message
)));
}
}
let _data_stream = match self.mode {
FtpMode::Passive => match self.passive_mode().await {
Ok(stream) => stream,
Err(e) => {
warn!("被动模式失败,尝试主动模式: {}", e);
self.active_mode().await?
}
},
FtpMode::Active => self.active_mode().await?,
};
self.send_command(&format!("RETR {}", remote_path)).await?;
let retr_resp = self.read_response().await?;
if !retr_resp.is_positive_preliminary() {
if retr_resp.code == 550 {
return Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 550 },
));
}
return Err(Aria2Error::DownloadFailed(format!(
"RETR 命令失败: {} {}",
retr_resp.code, retr_resp.message
)));
}
Err(Aria2Error::DownloadFailed(
"download_file 需要在数据连接建立后返回流,请使用更高级的 API".to_string(),
))
}
pub async fn cwd(&mut self, path: &str) -> Result<()> {
debug!("更改工作目录: {}", path);
self.send_command(&format!("CWD {}", path)).await?;
let resp = self.read_response().await?;
if resp.is_positive_completion() {
Ok(())
} else if resp.code == 550 {
Err(Aria2Error::Recoverable(
crate::error::RecoverableError::ServerError { code: 550 },
))
} else {
Err(Aria2Error::DownloadFailed(format!(
"CWD 命令失败: {} {}",
resp.code, resp.message
)))
}
}
pub async fn pwd(&mut self) -> Result<String> {
debug!("查询当前工作目录");
self.send_command("PWD").await?;
let resp = self.read_response().await?;
if resp.code == 257 {
let msg = resp.message.trim();
if let Some(start) = msg.find('"')
&& let Some(end) = msg.rfind('"')
&& end > start
{
let dir = &msg[start + 1..end];
debug!("当前目录: {}", dir);
return Ok(dir.to_string());
}
Ok(msg.to_string())
} else {
Err(Aria2Error::DownloadFailed(format!(
"PWD 命令失败: {} {}",
resp.code, resp.message
)))
}
}
pub async fn abort(&mut self) -> Result<()> {
debug!("发送 ABOR 命令中止传输");
self.send_command("ABOR").await?;
match self.read_response().await {
Ok(resp) => {
debug!("ABOR 响应: {} {}", resp.code, resp.message);
let mut buf = String::new();
match timeout(
Duration::from_secs(2),
self.control_stream.read_line(&mut buf),
)
.await
{
Ok(Ok(n)) if n > 0 => {
debug!("ABOR 第二个响应: {}", buf.trim());
}
_ => {}
}
Ok(())
}
Err(e) => {
warn!("ABOR 命令后连接状态异常(可能是正常的): {}", e);
Ok(())
}
}
}
pub async fn quit(mut self) -> Result<()> {
debug!("发送 QUIT 命令");
if let Err(e) = self.send_command("QUIT").await {
warn!("发送 QUIT 命令失败(连接可能已关闭): {}", e);
return Ok(());
}
match self.read_response().await {
Ok(resp) => {
info!("FTP 断开连接: {}", resp.message.trim());
Ok(())
}
Err(e) => {
warn!("读取 QUIT 响应失败: {}", e);
Ok(())
}
}
}
async fn send_command(&mut self, cmd: &str) -> Result<()> {
debug!("FTP 命令: {}", cmd.trim());
self.control_stream
.write_all(cmd.as_bytes())
.await
.map_err(|e| Aria2Error::Network(format!("发送 FTP 命令失败: {}", e)))?;
self.control_stream
.write_all(b"\r\n")
.await
.map_err(|e| Aria2Error::Network(format!("发送换行符失败: {}", e)))?;
self.control_stream
.flush()
.await
.map_err(|e| Aria2Error::Network(format!("刷新缓冲区失败: {}", e)))?;
Ok(())
}
async fn read_response(&mut self) -> Result<FtpResponse> {
let mut line = String::new();
let mut code: Option<u16> = None;
let mut message = String::new();
let mut is_multiline = false;
loop {
line.clear();
let bytes_read = timeout(self.read_timeout, self.control_stream.read_line(&mut line))
.await
.map_err(|_| Aria2Error::Recoverable(crate::error::RecoverableError::Timeout))?
.map_err(|e| Aria2Error::Network(format!("读取 FTP 响应失败: {}", e)))?;
if bytes_read == 0 {
break; }
let trimmed = line.trim_end();
if trimmed.len() < 4 {
continue;
}
let response_code: u16 = trimmed[..3].parse().unwrap_or(0);
if code.is_none() {
code = Some(response_code);
}
let separator = trimmed.as_bytes()[3];
if separator == b'-' && !is_multiline {
is_multiline = true;
message.push_str(&trimmed[4..]);
message.push('\n');
} else if separator == b' ' {
message.push_str(&trimmed[4..]);
break;
} else if is_multiline && trimmed.starts_with(&format!("{:3} ", code.unwrap_or(0))) {
message.push_str(&trimmed[4..]);
break;
} else if is_multiline {
message.push_str(&trimmed[4..]);
message.push('\n');
}
}
let code_val = code.unwrap_or(0);
debug!("FTP 响应: {} {}", code_val, message.trim());
Ok(FtpResponse {
code: code_val,
message,
})
}
fn parse_pasv_response(text: &str) -> Result<(String, u16)> {
let start = text
.find('(')
.ok_or_else(|| Aria2Error::Parse("PASV 响应缺少左括号".to_string()))?;
let end = text
.find(')')
.ok_or_else(|| Aria2Error::Parse("PASV 响应缺少右括号".to_string()))?;
let inner = &text[start + 1..end];
let parts: Vec<&str> = inner.split(',').collect();
if parts.len() != 6 {
return Err(Aria2Error::Parse(format!(
"PASV 响应格式错误: 期望 6 个部分,得到 {} 个",
parts.len()
)));
}
let h1: u8 = parts[0]
.trim()
.parse()
.map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h1".to_string()))?;
let h2: u8 = parts[1]
.trim()
.parse()
.map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h2".to_string()))?;
let h3: u8 = parts[2]
.trim()
.parse()
.map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h3".to_string()))?;
let h4: u8 = parts[3]
.trim()
.parse()
.map_err(|_| Aria2Error::Parse("PASV 响应: 无效的 IP 字节 h4".to_string()))?;
let p1: u16 = parts[4]
.trim()
.parse()
.map_err(|_| Aria2Error::Parse("PASV 响应: 无效的端口字节 p1".to_string()))?;
let p2: u16 = parts[5]
.trim()
.parse()
.map_err(|_| Aria2Error::Parse("PASV 响应: 无效的端口字节 p2".to_string()))?;
let host = format!("{}.{}.{}.{}", h1, h2, h3, h4);
let port = p1 * 256 + p2;
Ok((host, port))
}
fn parse_epsv_response(text: &str) -> Option<u16> {
let start = text.rfind('|')?;
let prev_pipe = text[..start].rfind('|')?;
let port_str = &text[prev_pipe + 1..start];
port_str.parse::<u16>().ok()
}
pub(crate) fn parse_list_line(line: &str) -> Option<FtpFileInfo> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if let Some(info) = Self::parse_unix_list_line(trimmed) {
return Some(info);
}
if let Some(info) = Self::parse_windows_list_line(trimmed) {
return Some(info);
}
if let Some(info) = Self::parse_mlsd_line(trimmed) {
return Some(info);
}
None
}
fn parse_list_line_fast(line: &str) -> Option<FtpFileInfo> {
if line.len() < 35 {
return None;
}
let entry_type = match line.as_bytes().first()? {
b'd' => true, b'-' => false, b'l' => {
false
}
_ => return None, };
let is_dir = entry_type;
let perms = &line[1..10];
if !perms.chars().all(|c| "rwxst-".contains(c)) {
return None;
}
let after_perms = line[10..].trim_start();
let mut pos = 0;
for _ in 0..7 {
let end = after_perms[pos..]
.find(' ')
.unwrap_or(after_perms.len() - pos);
pos += end + 1;
while pos < after_perms.len() && after_perms.as_bytes()[pos] == b' ' {
pos += 1;
}
if pos >= after_perms.len() {
return None;
}
}
let name_raw = after_perms[pos..].trim();
if name_raw.is_empty() {
return None;
}
let actual_name = if line.as_bytes()[0] == b'l' {
if let Some(arrow_pos) = name_raw.find(" -> ") {
&name_raw[..arrow_pos]
} else {
name_raw
}
} else {
name_raw
};
if actual_name == "." || actual_name == ".." {
return None;
}
let size_field = after_perms.split_whitespace().nth(3)?;
let size: u64 = size_field.parse().ok()?;
Some(FtpFileInfo {
name: actual_name.to_string(),
size,
is_dir,
})
}
fn parse_unix_list_line(line: &str) -> Option<FtpFileInfo> {
if let Some(info) = Self::parse_list_line_fast(line) {
return Some(info);
}
Self::parse_unix_list_line_regex(line)
}
fn parse_unix_list_line_regex(line: &str) -> Option<FtpFileInfo> {
use regex::Regex;
let re = Regex::new(
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+(.+)$"
).ok()?;
let caps = re.captures(line)?;
let type_char = caps.get(1)?.as_str().chars().next()?;
let is_dir = type_char == 'd';
let is_link = type_char == 'l';
let size: u64 = caps.get(6)?.as_str().parse().ok()?;
let name = caps.get(10)?.as_str();
if name.is_empty() {
return None;
}
let actual_name = if is_link {
if let Some(arrow_pos) = name.find(" -> ") {
&name[..arrow_pos]
} else {
name
}
} else {
name
};
if actual_name == "." || actual_name == ".." {
return None;
}
Some(FtpFileInfo {
name: actual_name.to_string(),
size,
is_dir,
})
}
fn parse_windows_list_line(line: &str) -> Option<FtpFileInfo> {
if line.len() < 20 {
return None;
}
let date_part = &line[..8];
if date_part.len() != 8
|| date_part.chars().nth(2)? != '-'
|| date_part.chars().nth(5)? != '-'
{
return None;
}
let after_date = line[8..].trim_start();
let space_pos = after_date.find(' ')?;
let time_part = &after_date[..space_pos];
if !time_part.contains(':') {
return None;
}
let after_time = after_date[space_pos + 1..].trim_start();
let space_pos = after_time.find(' ')?;
let size_or_dir = after_time[..space_pos].trim();
let is_dir = size_or_dir.eq_ignore_ascii_case("<DIR>");
let size: u64 = if is_dir { 0 } else { size_or_dir.parse().ok()? };
let name = after_time[space_pos + 1..].trim().to_string();
if name.is_empty() || name == "." || name == ".." {
return None;
}
Some(FtpFileInfo { name, size, is_dir })
}
fn parse_mlsd_line(line: &str) -> Option<FtpFileInfo> {
let semicolon_pos = line.rfind("; ")?;
let (facts_str, name) = line.split_at(semicolon_pos + 2);
let name = name.trim();
if name.is_empty() || name == "." || name == ".." {
return None;
}
let mut is_dir = false;
let mut size: u64 = 0;
for fact in facts_str.split(';') {
let fact = fact.trim();
if fact.is_empty() {
continue;
}
if let Some(eq_pos) = fact.find('=') {
let key = &fact[..eq_pos];
let value = &fact[eq_pos + 1..];
match key.to_lowercase().as_str() {
"type" => {
is_dir = value.eq_ignore_ascii_case("dir")
|| value.eq_ignore_ascii_case("cdir")
|| value.eq_ignore_ascii_case("pdir");
}
"size" => {
size = value.parse().unwrap_or(0);
}
_ => {}
}
}
}
Some(FtpFileInfo {
name: name.to_string(),
size,
is_dir,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ftp_response_checks() {
let ok = FtpResponse {
code: 226,
message: "Transfer complete".into(),
};
assert!(ok.is_success());
assert!(ok.is_positive_completion());
assert!(!ok.is_positive_preliminary());
let preliminary = FtpResponse {
code: 150,
message: "Opening data connection".into(),
};
assert!(preliminary.is_success());
assert!(!preliminary.is_positive_completion());
assert!(preliminary.is_positive_preliminary());
let error = FtpResponse {
code: 550,
message: "File not found".into(),
};
assert!(!error.is_success());
assert!(!error.is_positive_completion());
assert!(!error.is_positive_preliminary());
}
#[test]
fn test_parse_pasv_response_valid() {
let msg = "Entering Passive Mode (192,168,1,100,195,123)";
let result = FtpClient::parse_pasv_response(msg);
assert!(result.is_ok());
let (host, port) = result.unwrap();
assert_eq!(host, "192.168.1.100");
assert_eq!(port, 195 * 256 + 123); }
#[test]
fn test_parse_pasv_response_invalid() {
let msg = "Entering Passive Mode 192,168,1,100,195,123";
let result = FtpClient::parse_pasv_response(msg);
assert!(result.is_err());
let msg2 = "Entering Passive Mode (192,168,1,100,195)";
let result2 = FtpClient::parse_pasv_response(msg2);
assert!(result2.is_err());
}
#[test]
fn test_parse_epsv_response_valid() {
let msg = "Entering Extended Passive Mode (|||50001|)";
let result = FtpClient::parse_epsv_response(msg);
assert_eq!(result, Some(50001));
}
#[test]
fn test_parse_epsv_response_invalid() {
let msg = "Invalid EPSV response";
let result = FtpClient::parse_epsv_response(msg);
assert_eq!(result, None);
}
#[test]
fn test_parse_list_line_unix_regular_file() {
let line = "-rw-r--r-- 1 user staff 12345 Jan 15 10:30 document.pdf";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "document.pdf");
assert_eq!(info.size, 12345);
assert!(!info.is_dir);
}
#[test]
fn test_parse_list_line_unix_directory() {
let line = "drwxr-xr-x 2 user staff 4096 Feb 3 14:20 my_folder";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "my_folder");
assert_eq!(info.size, 4096);
assert!(info.is_dir);
}
#[test]
fn test_parse_list_line_unix_symlink() {
let line = "lrwxrwxrwx 1 user staff 8 Mar 10 09:00 link.txt -> target.txt";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "link.txt"); assert!(!info.is_dir);
}
#[test]
fn test_parse_list_line_unix_hidden_file() {
let line = "-rw-r--r-- 1 user staff 512 Apr 1 08:00 .bashrc";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, ".bashrc");
assert_eq!(info.size, 512);
assert!(!info.is_dir);
}
#[test]
fn test_parse_list_line_unix_special_entries() {
let dot = "drwxr-xr-x 2 user staff 4096 Jan 1 00:00 .";
let dotdot = "drwxr-xr-x 2 user staff 4096 Jan 1 00:00 ..";
assert!(FtpClient::parse_list_line(dot).is_none());
assert!(FtpClient::parse_list_line(dotdot).is_none());
}
#[test]
fn test_parse_list_line_windows_file() {
let line = "01-15-24 10:30AM 12345 document.pdf";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "document.pdf");
assert_eq!(info.size, 12345);
assert!(!info.is_dir);
}
#[test]
fn test_parse_list_line_windows_directory() {
let line = "02-03-24 02:20PM <DIR> my_folder";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "my_folder");
assert!(info.is_dir);
}
#[test]
fn test_parse_list_line_mlsd_format() {
let line = "type=file;size=12345;modify=20240115103000;unix.mode=0644; document.pdf";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "document.pdf");
assert_eq!(info.size, 12345);
assert!(!info.is_dir);
}
#[test]
fn test_parse_list_line_mlsd_directory() {
let line = "type=dir;size=4096;modify=20240203142000;unix.mode=0755; my_folder";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "my_folder");
assert_eq!(info.size, 4096);
assert!(info.is_dir);
}
#[test]
fn test_ftp_mode_default() {
let mode = FtpMode::default();
assert_eq!(mode, FtpMode::Passive);
}
#[test]
fn test_ftp_file_info_creation() {
let info = FtpFileInfo {
name: "test.txt".to_string(),
size: 1024,
is_dir: false,
};
assert_eq!(info.name, "test.txt");
assert_eq!(info.size, 1024);
assert!(!info.is_dir);
}
#[test]
fn test_parse_list_line_with_spaces_in_name() {
let line = "-rw-r--r-- 1 user staff 5678 Jan 20 11:00 my document with spaces.txt";
let result = FtpClient::parse_list_line(line);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.name, "my document with spaces.txt");
assert_eq!(info.size, 5678);
}
#[test]
fn test_parse_list_line_unrecognized_format() {
let line = "this is not a valid listing format";
let result = FtpClient::parse_list_line(line);
assert!(result.is_none());
}
#[test]
fn test_parse_pasv_edge_cases() {
let min_msg = "Entering Passive Mode (127,0,0,1,0,0)";
let min_result = FtpClient::parse_pasv_response(min_msg).unwrap();
assert_eq!(min_result.1, 0);
let max_msg = "Entering Passive Mode (255,255,255,255,255,255)";
let max_result = FtpClient::parse_pasv_response(max_msg).unwrap();
assert_eq!(max_result.1, 255 * 256 + 255); }
}