lib.rs
raw
//! 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<Box<dyn Backend>, 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<Url, Error> {
// 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<usize, Error> {
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()
}