1use std::path::Path;
9
10use serde::Serialize;
11use suppaftp::{RustlsConnector, RustlsFtpStream};
12
13use crate::config::ResolvedTarget;
14
15const FTPS_PORT: u16 = 990;
16const FTP_USER: &str = "bblp";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct FileEntry {
21 pub name: String,
22 pub is_dir: bool,
23 pub size: u64,
24}
25
26fn parse_list_line(line: &str) -> Option<FileEntry> {
29 let trimmed = line.trim_start();
32 if trimmed.starts_with("total ") || trimmed.is_empty() {
33 return None;
34 }
35 let f = suppaftp::list::File::try_from(line).ok()?;
36 let name = f.name().to_string();
37 if name == "." || name == ".." || name.is_empty() {
38 return None;
39 }
40 Some(FileEntry {
41 name,
42 is_dir: f.is_directory(),
43 size: f.size() as u64,
44 })
45}
46
47#[derive(Debug, thiserror::Error)]
49pub enum FtpError {
50 #[error("TLS setup failed: {0}")]
51 Tls(String),
52 #[error("FTP error: {0}")]
53 Ftp(String),
54 #[error("local file error: {0}")]
55 Io(#[from] std::io::Error),
56}
57
58fn part_path(local: &Path) -> std::path::PathBuf {
61 let mut name = local.file_name().unwrap_or_default().to_os_string();
62 name.push(".part");
63 local.with_file_name(name)
64}
65
66pub struct FtpsClient {
68 target: ResolvedTarget,
69}
70
71impl FtpsClient {
72 pub fn new(target: ResolvedTarget) -> Self {
73 Self { target }
74 }
75
76 fn connect(&self) -> Result<RustlsFtpStream, FtpError> {
77 let config = crate::tls::lan_client_config().map_err(|e| FtpError::Tls(e.to_string()))?;
78 let mut ftp = RustlsFtpStream::connect_secure_implicit(
79 (self.target.ip.as_str(), FTPS_PORT),
80 RustlsConnector::from(config),
81 &self.target.ip,
82 )
83 .map_err(|e| FtpError::Ftp(e.to_string()))?;
84 ftp.login(FTP_USER, &self.target.access_code)
85 .map_err(|e| FtpError::Ftp(e.to_string()))?;
86 Ok(ftp)
87 }
88
89 pub fn list(&self, dir: &str) -> Result<Vec<String>, FtpError> {
91 let mut ftp = self.connect()?;
92 let names = ftp
93 .nlst(Some(dir))
94 .map_err(|e| FtpError::Ftp(e.to_string()))?;
95 let _ = ftp.quit();
96 Ok(names)
97 }
98
99 pub fn list_entries(&self, dir: &str) -> Result<Vec<FileEntry>, FtpError> {
102 let mut ftp = self.connect()?;
103 let lines = ftp
104 .list(Some(dir))
105 .map_err(|e| FtpError::Ftp(e.to_string()))?;
106 let _ = ftp.quit();
107 Ok(lines.iter().filter_map(|l| parse_list_line(l)).collect())
108 }
109
110 pub fn upload(&self, local: &Path, remote_path: &str) -> Result<u64, FtpError> {
112 let mut file = std::fs::File::open(local)?;
113 let mut ftp = self.connect()?;
114 let result = ftp
115 .put_file(remote_path, &mut file)
116 .map_err(|e| FtpError::Ftp(e.to_string()));
117 let _ = ftp.quit();
118 result
119 }
120
121 pub fn download(&self, remote_path: &str, local: &Path) -> Result<u64, FtpError> {
127 let tmp = part_path(local);
128 let mut file = std::fs::File::create(&tmp)?;
129 let mut ftp = self.connect()?;
130 let result = ftp
131 .retr(remote_path, |reader| {
132 std::io::copy(reader, &mut file).map_err(suppaftp::FtpError::ConnectionError)
135 })
136 .map_err(|e| FtpError::Ftp(e.to_string()));
137 let _ = ftp.quit();
138 drop(file);
139 match result {
140 Ok(n) => {
141 std::fs::rename(&tmp, local)?;
142 Ok(n)
143 }
144 Err(e) => {
145 let _ = std::fs::remove_file(&tmp);
147 Err(e)
148 }
149 }
150 }
151
152 pub fn delete(&self, remote_path: &str) -> Result<(), FtpError> {
154 let mut ftp = self.connect()?;
155 let result = ftp
156 .rm(remote_path)
157 .map_err(|e| FtpError::Ftp(e.to_string()));
158 let _ = ftp.quit();
159 result
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn part_path_is_a_sibling_with_a_part_suffix() {
169 assert_eq!(
170 part_path(Path::new("/tmp/v.mp4")),
171 Path::new("/tmp/v.mp4.part")
172 );
173 assert_eq!(part_path(Path::new("out.jpg")), Path::new("out.jpg.part"));
174 }
175
176 #[test]
177 fn parses_unix_list_lines_into_entries() {
178 let dir = parse_list_line("drwxr-xr-x 2 root root 4096 Jan 01 12:00 cache").unwrap();
179 assert_eq!(dir.name, "cache");
180 assert!(dir.is_dir);
181 let file =
182 parse_list_line("-rw-r--r-- 1 root root 1234 Jan 01 12:00 coin.gcode.3mf").unwrap();
183 assert_eq!(file.name, "coin.gcode.3mf");
184 assert!(!file.is_dir);
185 assert_eq!(file.size, 1234);
186 assert_eq!(parse_list_line("total 8"), None);
188 assert_eq!(
189 parse_list_line("drwxr-xr-x 2 root root 4096 Jan 01 12:00 ."),
190 None
191 );
192 }
193}