//! Remote storage backends for beeping repositories. //! //! Each backend implements [`repository::Backend`] — write-once objects //! with get/put/contains/list/delete — over a different protocol: //! //! | scheme | backend | //! |-------------------|-------------------------------| //! | `ftp://` | [`ftp::FtpBackend`] | //! | `sftp://`, `ssh://` | [`sftp::SftpBackend`] | //! | `s3://` | [`s3::S3Backend`] | //! | `lftp:name/sub` | whatever the lftp bookmark `name` points at | //! //! The `lftp:` form resolves a bookmark by name from the user's lftp //! bookmark file and appends the optional subpath; the FTP and SFTP //! backends also consult the same file for credentials when a plain //! URL doesn't carry them (see [`lftp`]). //! //! Repository objects are sealed and integrity-bound client-side, so //! the transport carries only opaque data; even plain FTP reveals no //! more content than the storage host already holds. What a transport's //! security still governs is *access* — credentials that leak on the //! wire (classic FTP) let an attacker delete backups. //! //! FTP and SFTP sessions serve one command at a time, so those backends //! keep a pool of connections, growing to match however many pipeline //! threads use them concurrently, with one transparent retry on a fresh //! connection when a pooled one has gone stale. pub mod ftp; pub mod s3; pub mod sftp; mod lftp; mod pool; use repository::{Backend, Error}; use url::Url; /// Opens the backend a repository URL names. pub fn open_url(url: &Url) -> Result, Error> { match url.scheme() { "ftp" => Ok(Box::new(ftp::FtpBackend::from_url(url)?)), "sftp" | "ssh" => Ok(Box::new(sftp::SftpBackend::from_url(url)?)), "s3" => Ok(Box::new(s3::S3Backend::from_url(url)?)), "lftp" => { let resolved = resolve_bookmark_url(url)?; if resolved.scheme() == "lftp" { return Err(Error::Backend(format!( "lftp bookmark {url} resolves to another bookmark; \ that way lies madness" ))); } open_url(&resolved) } other => Err(Error::Backend(format!( "unsupported repository scheme {other:?}" ))), } } /// Resolves `lftp:name` or `lftp:name/sub/path` to the bookmarked URL /// with the subpath appended. fn resolve_bookmark_url(url: &Url) -> Result { // Accept both the opaque form (lftp:name/sub) and the authority // form (lftp://name/sub) that URL habits produce let spec = match url.host_str() { Some(host) => format!("{host}{}", url.path()), None => url.path().to_string(), }; let (name, subpath) = match spec.split_once('/') { Some((name, subpath)) => (name, subpath), None => (spec.as_str(), ""), }; let location = lftp::bookmark_location(name) .ok_or_else(|| Error::Backend(format!("no lftp bookmark named {name:?} was found")))?; let mut resolved = Url::parse(&location).map_err(|err| { Error::Backend(format!( "lftp bookmark {name:?} is not a usable URL ({location:?}): {err}" )) })?; if !subpath.is_empty() { let base = resolved.path().trim_end_matches('/').to_string(); resolved.set_path(&format!("{base}/{subpath}")); } Ok(resolved) } /// Reads the `?connections=N` URL parameter capping a backend's /// connection pool, rejecting any parameter it doesn't recognize. pub(crate) fn connection_limit(url: &Url, default: usize) -> Result { let mut limit = default; for (key, value) in url.query_pairs() { match key.as_ref() { "connections" => { limit = value.parse().ok().filter(|&n| n >= 1).ok_or_else(|| { Error::Backend(format!( "connections must be a positive number, not {value:?}" )) })?; } other => { return Err(Error::Backend(format!("unknown URL parameter {other:?}"))); } } } Ok(limit) } /// Decodes URL percent-encoding, as found in URL userinfo and lftp /// bookmark files. pub(crate) fn percent_decode(text: &str) -> String { let bytes = text.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() { let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok(); if let Some(value) = hex.and_then(|hex| u8::from_str_radix(hex, 16).ok()) { out.push(value); index += 3; continue; } } out.push(bytes[index]); index += 1; } String::from_utf8_lossy(&out).into_owned() }