Skip to main content

remote_fs/
ftp.rs

1use std::io::Cursor;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4
5use async_ftp::FtpStream;
6use async_trait::async_trait;
7use tokio::io::AsyncReadExt;
8
9use crate::error::{Error, Result};
10use crate::traits::RemoteFileSystem;
11use crate::types::{FileEntry, FileMeta, FtpConfig};
12
13/// FTP remote file system client (backed by `async-ftp`).
14pub struct FtpClient {
15    config: FtpConfig,
16    stream: Option<FtpStream>,
17}
18
19impl FtpClient {
20    pub fn new(config: FtpConfig) -> Self {
21        Self {
22            config,
23            stream: None,
24        }
25    }
26
27    fn stream(&mut self) -> Result<&mut FtpStream> {
28        self.stream.as_mut().ok_or(Error::NotConnected)
29    }
30
31    fn addr(&self) -> String {
32        format!("{}:{}", self.config.host, self.config.port)
33    }
34}
35
36/// Parse a single FTP LIST line (Unix or DOS-ish).
37fn parse_list_line(base: &str, line: &str) -> Option<FileEntry> {
38    let line = line.trim();
39    if line.is_empty() {
40        return None;
41    }
42
43    // Unix: drwxr-xr-x 2 user group 4096 Jan 1 12:00 name
44    if line.starts_with('d')
45        || line.starts_with('-')
46        || line.starts_with('l')
47        || line.starts_with('b')
48        || line.starts_with('c')
49    {
50        let parts: Vec<&str> = line.split_whitespace().collect();
51        if parts.len() < 9 {
52            return None;
53        }
54        let name = parts[8..].join(" ");
55        if name == "." || name == ".." {
56            return None;
57        }
58        let is_dir = parts[0].starts_with('d');
59        let is_symlink = parts[0].starts_with('l');
60        let size = parts[4].parse().unwrap_or(0);
61        let path = join_ftp(base, &name);
62        return Some(FileEntry {
63            path,
64            name,
65            is_dir,
66            is_file: !is_dir && !is_symlink,
67            is_symlink,
68            size,
69            modified: None,
70        });
71    }
72
73    // DOS: 01-01-26  12:00AM       <DIR>          name
74    //      01-01-26  12:00AM                  123 name
75    let parts: Vec<&str> = line.split_whitespace().collect();
76    if parts.len() >= 4 {
77        let is_dir = parts[2].eq_ignore_ascii_case("<DIR>");
78        let (size, name_idx) = if is_dir {
79            (0u64, 3)
80        } else {
81            (parts[2].parse().unwrap_or(0), 3)
82        };
83        if name_idx >= parts.len() {
84            return None;
85        }
86        let name = parts[name_idx..].join(" ");
87        if name == "." || name == ".." {
88            return None;
89        }
90        let path = join_ftp(base, &name);
91        return Some(FileEntry {
92            path,
93            name,
94            is_dir,
95            is_file: !is_dir,
96            is_symlink: false,
97            size,
98            modified: None,
99        });
100    }
101
102    None
103}
104
105fn join_ftp(base: &str, name: &str) -> PathBuf {
106    if base.is_empty() || base == "/" {
107        PathBuf::from(format!("/{name}"))
108    } else {
109        PathBuf::from(base.trim_end_matches('/')).join(name)
110    }
111}
112
113#[async_trait]
114impl RemoteFileSystem for FtpClient {
115    async fn connect(&mut self) -> Result<()> {
116        let mut stream = FtpStream::connect(self.addr().as_str()).await?;
117        stream
118            .login(&self.config.username, &self.config.password)
119            .await?;
120        let _ = self.config.passive; // async-ftp uses passive by default
121        self.stream = Some(stream);
122        Ok(())
123    }
124
125    async fn disconnect(&mut self) -> Result<()> {
126        if let Some(mut stream) = self.stream.take() {
127            let _ = stream.quit().await;
128        }
129        Ok(())
130    }
131
132    fn is_connected(&self) -> bool {
133        self.stream.is_some()
134    }
135
136    async fn list(&mut self, path: &str) -> Result<Vec<FileEntry>> {
137        let stream = self.stream()?;
138        let pathname = if path.is_empty() || path == "/" {
139            None
140        } else {
141            Some(path)
142        };
143        let lines = stream.list(pathname).await?;
144        Ok(lines
145            .iter()
146            .filter_map(|line| parse_list_line(path, line))
147            .collect())
148    }
149
150    async fn metadata(&mut self, path: &str) -> Result<FileMeta> {
151        let stream = self.stream()?;
152        let size = stream.size(path).await?;
153        let modified = stream
154            .mdtm(path)
155            .await?
156            .map(|dt| SystemTime::from(dt));
157
158        if let Some(size) = size {
159            return Ok(FileMeta {
160                path: PathBuf::from(path),
161                is_dir: false,
162                is_file: true,
163                is_symlink: false,
164                size: size as u64,
165                modified,
166            });
167        }
168
169        // Might be a directory — try listing it.
170        match stream.list(Some(path)).await {
171            Ok(_) => Ok(FileMeta {
172                path: PathBuf::from(path),
173                is_dir: true,
174                is_file: false,
175                is_symlink: false,
176                size: 0,
177                modified,
178            }),
179            Err(err) => Err(err.into()),
180        }
181    }
182
183    async fn exists(&mut self, path: &str) -> Result<bool> {
184        match self.metadata(path).await {
185            Ok(_) => Ok(true),
186            Err(_) => Ok(false),
187        }
188    }
189
190    async fn mkdir(&mut self, path: &str) -> Result<()> {
191        self.stream()?.mkdir(path).await?;
192        Ok(())
193    }
194
195    async fn mkdir_all(&mut self, path: &str) -> Result<()> {
196        let path = path.trim_matches('/');
197        if path.is_empty() {
198            return Ok(());
199        }
200        let mut current = String::new();
201        for part in path.split('/').filter(|p| !p.is_empty()) {
202            current = if current.is_empty() {
203                format!("/{part}")
204            } else {
205                format!("{current}/{part}")
206            };
207            if !self.exists(&current).await? {
208                self.mkdir(&current).await?;
209            }
210        }
211        Ok(())
212    }
213
214    async fn rmdir(&mut self, path: &str) -> Result<()> {
215        self.stream()?.rmdir(path).await?;
216        Ok(())
217    }
218
219    async fn remove_file(&mut self, path: &str) -> Result<()> {
220        self.stream()?.rm(path).await?;
221        Ok(())
222    }
223
224    async fn remove(&mut self, path: &str) -> Result<()> {
225        let meta = self.metadata(path).await?;
226        if meta.is_dir {
227            let children = self.list(path).await?;
228            for child in children {
229                let child_path = child.path.to_string_lossy().to_string();
230                Box::pin(self.remove(&child_path)).await?;
231            }
232            self.rmdir(path).await
233        } else {
234            self.remove_file(path).await
235        }
236    }
237
238    async fn rename(&mut self, from: &str, to: &str) -> Result<()> {
239        self.stream()?.rename(from, to).await?;
240        Ok(())
241    }
242
243    async fn read(&mut self, path: &str) -> Result<Vec<u8>> {
244        let cursor = self.stream()?.simple_retr(path).await?;
245        Ok(cursor.into_inner())
246    }
247
248    async fn write(&mut self, path: &str, data: &[u8]) -> Result<()> {
249        let mut reader = Cursor::new(data.to_vec());
250        self.stream()?.put(path, &mut reader).await?;
251        Ok(())
252    }
253
254    async fn download(&mut self, remote: &str, local: &Path) -> Result<()> {
255        let data = self.read(remote).await?;
256        tokio::fs::write(local, data).await?;
257        Ok(())
258    }
259
260    async fn upload(&mut self, local: &Path, remote: &str) -> Result<()> {
261        let data = tokio::fs::read(local).await?;
262        self.write(remote, &data).await
263    }
264}
265
266#[allow(dead_code)]
267async fn read_data_stream<R: AsyncReadExt + Unpin>(mut reader: R) -> Result<Vec<u8>> {
268    let mut buf = Vec::new();
269    reader.read_to_end(&mut buf).await?;
270    Ok(buf)
271}