//! FTP backend. //! //! Repository objects are already sealed client-side, so plain FTP only //! exposes what any storage host sees anyway; the notable residual risk //! is that classic FTP sends the *credentials* in the clear, so anyone //! on the wire can gain the access needed to delete backups. //! //! URL form: `ftp://user:password@host:port/path`. Credentials are //! resolved most-specific first: the URL itself, `$BEEPING_FTP_PASSWORD`, //! then a matching lftp bookmark (see [`crate::lftp`]); with nothing //! found, the login is `anonymous` with an empty password — plenty of //! NAS boxes serve exactly that. Writing `ftp://anonymous@host/...` //! forces anonymous login even when a bookmark holds named credentials //! for the host. //! //! At most `?connections=N` (default 4) connections are held at once, //! and a server that refuses further logins (421) is waited out rather //! than treated as an error — see [`crate::pool`]. use std::sync::{ Mutex, atomic::{AtomicBool, Ordering}, }; use repository::{Backend, ChunkId, Error, ObjectKey, ObjectKind}; use suppaftp::{FtpError, FtpStream, Status, types::FileType}; use url::Url; use crate::{ lftp, percent_decode, pool::{Pool, temp_suffix}, }; const DEFAULT_CONNECTIONS: usize = 4; pub struct FtpBackend { host: String, port: u16, user: String, password: String, root: String, pool: Pool, /// Directories already created this session, to avoid a MKD round /// trip per stored object. known_dirs: Mutex>, /// Set once the server has declined to list machine-readably, which /// is the only way to learn what the repository occupies. Asking /// again would cost a retry and a fresh dial for the same refusal. size_unknown: AtomicBool, } impl FtpBackend { pub fn from_url(url: &Url) -> Result { let host = url .host_str() .ok_or_else(|| Error::Backend(format!("{url} has no host")))? .to_string(); let port = url.port().unwrap_or(21); let mut user = match url.username() { "" => None, user => Some(percent_decode(user)), }; let mut password = url.password().map(percent_decode); if password.is_none() && let Ok(env) = std::env::var("BEEPING_FTP_PASSWORD") { password = Some(env); } if (user.is_none() || password.is_none()) && let Some(found) = lftp::find_credentials("ftp", &host, port, user.as_deref()) { user.get_or_insert(found.user); if password.is_none() { password = found.password; } } let user = user.unwrap_or_else(|| "anonymous".to_string()); let password = password.unwrap_or_default(); Ok(FtpBackend { host, port, user, password, root: url.path().trim_end_matches('/').to_string(), pool: Pool::new(crate::connection_limit(url, DEFAULT_CONNECTIONS)?), size_unknown: AtomicBool::new(false), known_dirs: Mutex::new(std::collections::HashSet::new()), }) } fn with_connection( &self, op: impl FnMut(&mut FtpStream) -> Result, ) -> Result { self.pool.with(|| self.connect(), is_server_full, op) } fn connect(&self) -> Result { let mut ftp = FtpStream::connect((self.host.as_str(), self.port)).map_err(ftp_error)?; ftp.login(&self.user, &self.password).map_err(ftp_error)?; ftp.transfer_type(FileType::Binary).map_err(ftp_error)?; Ok(ftp) } fn object_path(&self, key: &ObjectKey) -> String { let mut path = self.root.clone(); for segment in key.segments() { path.push('/'); path.push_str(&segment); } path } fn dir_path(&self, segments: &[&str]) -> String { let mut path = self.root.clone(); for segment in segments { path.push('/'); path.push_str(segment); } path } /// Creates the directories leading to `key` — the repository root /// and every intermediate — remembering successes. MKD failures are /// ignored here: "already exists" is not reliably distinguishable /// in FTP, and a real problem surfaces as a failed STOR immediately /// after. fn ensure_parents(&self, ftp: &mut FtpStream, key: &ObjectKey) { let segments = key.segments(); let mut directories = Vec::new(); if !self.root.is_empty() { directories.push(self.root.clone()); } let mut path = self.root.clone(); // All but the last segment are directories for segment in &segments[..segments.len() - 1] { path.push('/'); path.push_str(segment); directories.push(path.clone()); } for directory in directories { let mut known = self.known_dirs.lock().unwrap(); if known.contains(&directory) { continue; } let _ = ftp.mkdir(&directory); known.insert(directory); } } } impl Backend for FtpBackend { fn put(&self, key: &ObjectKey, data: &[u8]) -> Result<(), Error> { let path = self.object_path(key); self.with_connection(|ftp| { if ftp.size(&path).is_ok() { // Objects are write-once; it is already this one return Ok(()); } self.ensure_parents(ftp, key); // Upload beside the final name, then rename into place // so no reader (or crash) ever sees a partial object let temp = format!("{path}{}", temp_suffix()); ftp.put_file(&temp, &mut std::io::Cursor::new(data)) .map_err(ftp_error)?; match ftp.rename(&temp, &path) { Ok(()) => Ok(()), Err(_) if ftp.size(&path).is_ok() => { // A concurrent writer beat us to it; ours is // surplus let _ = ftp.rm(&temp); Ok(()) } Err(err) => Err(ftp_error(err)), } }) } fn get(&self, key: &ObjectKey) -> Result>, Error> { let path = self.object_path(key); self.with_connection(|ftp| match ftp.retr_as_buffer(&path) { Ok(buffer) => Ok(Some(buffer.into_inner())), Err(err) if is_not_found(&err) => Ok(None), Err(err) => Err(ftp_error(err)), }) } fn contains(&self, key: &ObjectKey) -> Result { let path = self.object_path(key); self.with_connection(|ftp| match ftp.size(&path) { Ok(_) => Ok(true), Err(err) if is_not_found(&err) => Ok(false), Err(err) => Err(ftp_error(err)), }) } fn list( &self, kind: ObjectKind, visit: &mut dyn FnMut(ObjectKey) -> Result<(), Error>, ) -> Result<(), Error> { match kind { ObjectKind::Header => { if self.contains(&ObjectKey::Header)? { visit(ObjectKey::Header)?; } } // The flat kinds: one directory of hex-named objects each ObjectKind::Snapshot | ObjectKind::Prune | ObjectKind::Lock => { for name in self.dir_names(&self.dir_path(&[kind.directory()]))? { if let Some(key) = kind.key_for(&name) { visit(key)?; } } } ObjectKind::Chunk => { for prefix in self.dir_names(&self.dir_path(&["chunks"]))? { if prefix.len() != 2 || !prefix.bytes().all(|b| b.is_ascii_hexdigit()) { continue; } for name in self.dir_names(&self.dir_path(&["chunks", &prefix]))? { if let Ok(id) = ChunkId::from_hex(name) { visit(ObjectKey::Chunk(id))?; } } } } } Ok(()) } /// Adds up everything under the repository directory, from MLSD /// listings — one per directory, so a few hundred round trips for /// the fanned-out chunk store. Asked once a run, never on a hot /// path. /// /// MLSD is the only listing FTP has whose sizes can be read without /// guessing at a server's chosen `ls` format. A server without it /// cannot say what it holds, and says so once rather than being /// asked again. fn used_space(&self) -> Result, Error> { if self.size_unknown.load(Ordering::Acquire) { return Ok(None); } let root = self.root.clone(); match self.with_connection(|ftp| tree_bytes(ftp, &root)) { Ok(total) => Ok(Some(total)), Err(_) => { self.size_unknown.store(true, Ordering::Release); Ok(None) } } } fn delete(&self, key: &ObjectKey) -> Result<(), Error> { let path = self.object_path(key); self.with_connection(|ftp| match ftp.rm(&path) { Ok(()) => Ok(()), Err(err) if is_not_found(&err) => Ok(()), Err(err) => Err(ftp_error(err)), }) } } impl FtpBackend { /// The entry names within a directory, treating a missing directory /// as empty. Servers differ on whether NLST returns bare names or /// full paths, so paths are reduced to their final component. fn dir_names(&self, path: &str) -> Result, Error> { self.with_connection(|ftp| match ftp.nlst(Some(path)) { Ok(names) => Ok(names .into_iter() .map(|name| { name.rsplit('/') .next() .expect("rsplit always yields at least one part") .to_string() }) .collect()), Err(err) if is_not_found(&err) => Ok(Vec::new()), Err(err) => Err(ftp_error(err)), }) } } /// How many bytes the tree under `path` holds, by the sizes MLSD /// reports. A directory that is not there holds nothing — the /// repository's own subdirectories appear only once something has been /// put in them. fn tree_bytes(ftp: &mut FtpStream, path: &str) -> Result { let lines = match ftp.mlsd(Some(path)) { Ok(lines) => lines, Err(err) if is_not_found(&err) => return Ok(0), Err(err) => return Err(ftp_error(err)), }; let mut total = 0; for line in lines { let Some(listed) = Listed::parse(&line) else { continue; }; total += match listed.kind { // Servers differ on whether the name is bare or a full // path, exactly as they do for NLST ListedKind::Directory => tree_bytes(ftp, &format!("{path}/{}", listed.name))?, ListedKind::File => listed.size, }; } Ok(total) } /// One entry of an MLSD listing: `fact=value;...; name`, per RFC 3659. struct Listed<'a> { kind: ListedKind, size: u64, name: &'a str, } enum ListedKind { File, Directory, } impl<'a> Listed<'a> { /// Reads one listing line, or `None` for the entries that are not /// objects of their own: the directory itself, its parent, and /// anything whose type the server did not give. fn parse(line: &'a str) -> Option> { let (facts, name) = line.trim_end().split_once(' ')?; let mut kind = None; let mut size = 0; for fact in facts.split(';').filter(|fact| !fact.is_empty()) { let Some((key, value)) = fact.split_once('=') else { continue; }; match key.to_ascii_lowercase().as_str() { "type" => { kind = match value.to_ascii_lowercase().as_str() { "file" => Some(ListedKind::File), "dir" => Some(ListedKind::Directory), // cdir and pdir are this directory and its // parent, listed for navigation _ => return None, }; } "size" => size = value.parse().unwrap_or(0), _ => {} } } Some(Listed { kind: kind?, size, name: name.rsplit('/').next().expect("rsplit yields a part"), }) } } /// FTP reports a missing file as 550; the code is also used for /// permission problems, but that ambiguity is inherent to the protocol. fn is_not_found(err: &FtpError) -> bool { matches!( err, FtpError::UnexpectedResponse(response) if response.status == Status::FileUnavailable ) } /// Maps FTP errors into backend errors, giving 421 — the server /// declining service, most commonly "too many users already logged /// in" — its own variant so the connection pool can wait it out rather /// than fail. fn ftp_error(err: FtpError) -> Error { if matches!( &err, FtpError::UnexpectedResponse(response) if response.status == Status::NotAvailable ) { return Error::BackendBusy(format!("ftp: {err}")); } Error::Backend(format!("ftp: {err}")) } fn is_server_full(err: &Error) -> bool { matches!(err, Error::BackendBusy(_)) } #[cfg(test)] mod tests { use super::*; // These use a fictional host so no real lftp bookmark can match, // and they tolerate $BEEPING_FTP_PASSWORD being set in the // developer's environment rather than mutating process env. #[test] fn credentialless_urls_default_to_anonymous() { let url = Url::parse("ftp://nas.invalid/backups/repo").unwrap(); let backend = FtpBackend::from_url(&url).unwrap(); assert_eq!(backend.user, "anonymous"); if std::env::var("BEEPING_FTP_PASSWORD").is_err() { assert_eq!(backend.password, ""); } } #[test] fn an_explicit_anonymous_user_gets_an_empty_password() { let url = Url::parse("ftp://anonymous@nas.invalid/backups/repo").unwrap(); let backend = FtpBackend::from_url(&url).unwrap(); assert_eq!(backend.user, "anonymous"); if std::env::var("BEEPING_FTP_PASSWORD").is_err() { assert_eq!(backend.password, ""); } } #[test] fn url_credentials_pass_through_decoded() { let url = Url::parse("ftp://alice:p%40ss@nas.invalid:2121/repo").unwrap(); let backend = FtpBackend::from_url(&url).unwrap(); assert_eq!(backend.user, "alice"); assert_eq!(backend.password, "p@ss"); assert_eq!(backend.port, 2121); assert_eq!(backend.root, "/repo"); } /// MLSD is the one FTP listing whose sizes can be read without /// guessing at a server's `ls` format, and the facts it carries are /// unordered, case-insensitive, and open-ended. #[test] fn mlsd_lines_give_up_their_sizes() { let file = Listed::parse("type=file;size=1048576;modify=20260823090000; ab12cd") .expect("a file entry"); assert!(matches!(file.kind, ListedKind::File)); assert_eq!(file.size, 1048576); assert_eq!(file.name, "ab12cd"); // Order and case are the server's business, and unknown facts // are to be ignored rather than tripped over let directory = Listed::parse("perm=fle;Type=dir;unique=12U1; chunks").expect("a directory"); assert!(matches!(directory.kind, ListedKind::Directory)); assert_eq!(directory.name, "chunks"); // Some servers answer with full paths, as they do for NLST assert_eq!( Listed::parse("type=dir; /backups/repo/chunks/00") .expect("a directory") .name, "00" ); // A name may hold spaces; only the first one ends the facts assert_eq!( Listed::parse("type=file;size=3; two words") .expect("a file") .name, "two words" ); // This directory and its parent are listed for navigation, and // adding them up would count the tree twice over assert!(Listed::parse("type=cdir;modify=20260823090000; .").is_none()); assert!(Listed::parse("type=pdir; ..").is_none()); // Nothing to make of these assert!(Listed::parse("size=12; no-type").is_none()); assert!(Listed::parse("").is_none()); assert!(Listed::parse("type=file;size=7;").is_none()); } }