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()
}