Skip to main content

bambu_rs/
ftp.rs

1//! FTPS file transfer — the printer's LAN file store.
2//!
3//! **Implicit** FTPS on port 990, same `bblp` + access-code auth as MQTT, and the
4//! same self-signed X.509 **v1** certificate — so it shares the LAN rustls config
5//! ([`crate::tls`]), which accepts any certificate (the rustls equivalent of OpenSSL
6//! `CERT_NONE`), acceptable only for this LAN-direct self-signed case.
7
8use 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/// One entry from a directory listing.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct FileEntry {
21    pub name: String,
22    pub is_dir: bool,
23    pub size: u64,
24}
25
26/// Parse one FTP `LIST` line into a [`FileEntry`]; `None` for unparseable lines
27/// (a `total N` header, blank lines, the `.`/`..` pseudo-entries).
28fn parse_list_line(line: &str) -> Option<FileEntry> {
29    // `ls -l` prefixes a `total N` summary line; suppaftp's lenient parser would
30    // otherwise turn it into a bogus entry named "total N".
31    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/// Errors from FTPS operations. Messages never include the access code.
48#[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
58/// The temp path a download streams to before the atomic rename: `<local>.part`
59/// (same directory, so the rename stays on one filesystem and is atomic).
60fn 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
66/// A one-shot FTPS client (connect → act → quit per call).
67pub 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    /// List file names in `dir` (FTP `NLST`).
90    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    /// List `dir` with directory/size info (FTP `LIST`, parsed). Unparseable
100    /// lines (e.g. a `total N` header) are skipped.
101    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    /// Upload a local file to `remote_path` on the printer; returns bytes sent.
111    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    /// Download `remote_path` from the printer to `local`; returns bytes written.
122    /// Streams (FTP `RETR`) so large files (e.g. timelapse videos) aren't held
123    /// in memory. Writes to a sibling temp file and atomically renames on
124    /// success, so a failed or partial transfer never clobbers an existing
125    /// destination.
126    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                // The closure must return suppaftp's FtpResult; wrap any local
133                // write error as a connection error so RETR is finalised cleanly.
134                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                // Leave no partial file behind on failure.
146                let _ = std::fs::remove_file(&tmp);
147                Err(e)
148            }
149        }
150    }
151
152    /// Delete `remote_path` on the printer (FTP `DELE`).
153    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        // Pseudo-entries and headers are skipped.
187        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}