config.rs raw

use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result, bail};
use repository::{Backend, LocalBackend};

/// The optional config file, `~/.config/beeping/config.toml`. Everything
/// in it can also be given (and is overridden) on the command line.
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
    /// Repository location, e.g. `/mnt/backups/repo`.
    pub repository: Option<String>,
    /// Glob patterns excluded from every backup; see `beeping --help`
    /// (or [`compile_exclude`]) for the matching rules.
    #[serde(default)]
    pub exclude: Vec<String>,
    /// Allow backups to descend into other filesystems.
    #[serde(default)]
    pub cross_mount_points: bool,
    /// Room to leave on the store: once it has less than this left, a
    /// backup finishes early rather than filling it up. Written as a
    /// size (`"20 GiB"`) or a plain number of bytes.
    #[serde(default, deserialize_with = "deserialize_size")]
    pub minimum_free_space: Option<u64>,
    /// A size for the repository to stay under, for a store that can
    /// say what it holds but not what it has left — an FTP account with
    /// a quota, most of all.
    #[serde(default, deserialize_with = "deserialize_size")]
    pub maximum_store_size: Option<u64>,
}

impl ConfigFile {
    pub fn load() -> Result<ConfigFile> {
        let Some(config_dir) = dirs::config_dir() else {
            return Ok(ConfigFile::default());
        };

        let path = config_dir.join("beeping/config.toml");

        let text = match std::fs::read_to_string(&path) {
            Ok(text) => text,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                return Ok(ConfigFile::default());
            }
            Err(err) => return Err(err).context(format!("reading {path:?}")),
        };

        toml::from_str(&text).context(format!("parsing {path:?}"))
    }
}

/// Reads a size from the config file, where it may be written either as
/// a string with a unit or as a plain number of bytes.
fn deserialize_size<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::Deserialize as _;

    #[derive(serde::Deserialize)]
    #[serde(untagged)]
    enum Size {
        Text(String),
        Bytes(i64),
    }

    let Some(size) = Option::<Size>::deserialize(deserializer)? else {
        return Ok(None);
    };

    match size {
        Size::Text(text) => parse_size(&text)
            .map(Some)
            .map_err(serde::de::Error::custom),
        Size::Bytes(bytes) => u64::try_from(bytes)
            .map(Some)
            .map_err(|_| serde::de::Error::custom(format!("{bytes} is not a size"))),
    }
}

/// Parses a byte size: a bare number of bytes, or a number with a unit
/// suffix counted in powers of 1024 — `10G`, `10GB`, and `10 GiB` are
/// the same ten gibibytes. Fractions are allowed, so `1.5T` means what
/// it looks like.
pub fn parse_size(text: &str) -> Result<u64, String> {
    let text = text.trim();
    let split = text
        .find(|c: char| !c.is_ascii_digit() && c != '.')
        .unwrap_or(text.len());
    let (number, unit) = text.split_at(split);

    let value: f64 = number
        .parse()
        .map_err(|_| format!("{text:?} does not start with a number"))?;

    let scale: u64 = match unit.trim().to_ascii_lowercase().as_str() {
        "" | "b" => 1,
        "k" | "kb" | "kib" => 1 << 10,
        "m" | "mb" | "mib" => 1 << 20,
        "g" | "gb" | "gib" => 1 << 30,
        "t" | "tb" | "tib" => 1 << 40,
        "p" | "pb" | "pib" => 1 << 50,
        other => {
            return Err(format!(
                "{other:?} is not a size unit (try K, M, G, T, or P)"
            ));
        }
    };

    if !value.is_finite() || value < 0.0 {
        return Err(format!("{text:?} is not a size"));
    }

    let bytes = value * scale as f64;

    if bytes > u64::MAX as f64 {
        return Err(format!("{text:?} is larger than any store"));
    }

    Ok(bytes as u64)
}

/// Resolves a repository location string to a backend.
///
/// A plain path (optionally `~`-prefixed) or a `file://` URL is local
/// directory storage; `ftp://`, `sftp://`/`ssh://`, and `s3://` URLs
/// name the remote backends; `lftp:name/subpath` points at whatever an
/// lftp bookmark holds (see the `backends` crate for URL forms and
/// credential sources).
pub fn open_backend(location: &str) -> Result<Box<dyn Backend>> {
    if location.starts_with("lftp:") {
        let url = url::Url::parse(location)
            .with_context(|| format!("parsing repository location {location:?}"))?;

        return Ok(backends::open_url(&url)?);
    }

    match location.split_once("://").map(|(scheme, _)| scheme) {
        None | Some("file") => {
            let path = location.strip_prefix("file://").unwrap_or(location);
            let path = expand_home(Path::new(path))?;

            Ok(Box::new(LocalBackend::new(path)?))
        }
        Some(_) => {
            let url = url::Url::parse(location)
                .with_context(|| format!("parsing repository URL {location:?}"))?;

            Ok(backends::open_url(&url)?)
        }
    }
}

/// The repository location from CLI or config, whichever is given.
pub fn repository_location(cli: Option<String>, config: &ConfigFile) -> Result<String> {
    cli.or_else(|| config.repository.clone()).context(
        "no repository configured; pass --repository or set `repository` \
         in ~/.config/beeping/config.toml",
    )
}

/// The password for the repository, from $BEEPING_PASSWORD or an
/// interactive prompt. `confirm` asks twice, for repository creation.
pub fn password(confirm: bool) -> Result<String> {
    if let Ok(password) = std::env::var("BEEPING_PASSWORD") {
        return Ok(password);
    }

    let password = rpassword::prompt_password("Repository password: ")?;

    if confirm {
        let again = rpassword::prompt_password("Confirm password: ")?;

        if password != again {
            bail!("passwords do not match");
        }
    }

    Ok(password)
}

/// Builds the exclude set from the config file plus the command line,
/// under the pattern semantics of [`beeping::patterns`].
pub fn exclude_globs(config: &ConfigFile, cli: &[String]) -> Result<globset::GlobSet> {
    Ok(beeping::patterns::compile_set(
        config.exclude.iter().chain(cli),
    )?)
}

/// Where all of beeping's resumable working state lives; individual
/// operations get subdirectories of this. Backups protect this whole
/// directory from being captured.
pub fn state_base() -> Result<PathBuf> {
    let base = dirs::state_dir()
        .or_else(dirs::cache_dir)
        .context("no user state directory available; pass --state-dir")?;

    Ok(base.join("beeping"))
}

/// Where local chunk caches live, one file per repository. Regenerable
/// speed-up data, kept apart from the resumable state so that losing it
/// costs nothing but round trips. Backups protect it from being
/// captured, like the state directory.
pub fn cache_base() -> Result<PathBuf> {
    let base = dirs::cache_dir().context("no user cache directory available")?;

    Ok(base.join("beeping/chunks"))
}

/// A dedicated state directory for one resumable operation, derived from
/// the operation's identity so that rerunning the same command finds its
/// suspended state automatically.
pub fn default_state_dir(discriminator: &[&[u8]]) -> Result<PathBuf> {
    use sha2::Digest as _;

    let mut digest = sha2::Sha256::new();
    for part in discriminator {
        digest.update((part.len() as u64).to_be_bytes());
        digest.update(part);
    }

    let name = hex::encode(&digest.finalize()[..8]);

    Ok(state_base()?.join(name))
}

fn expand_home(path: &Path) -> Result<PathBuf> {
    let Ok(rest) = path.strip_prefix("~") else {
        return Ok(path.to_owned());
    };

    let home = dirs::home_dir().context("cannot expand ~: no home directory")?;

    Ok(home.join(rest))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sizes_are_read_with_or_without_a_unit() {
        assert_eq!(parse_size("0"), Ok(0));
        assert_eq!(parse_size("4096"), Ok(4096));
        assert_eq!(parse_size("10G"), Ok(10 << 30));
        assert_eq!(parse_size("10 GiB"), Ok(10 << 30));
        assert_eq!(parse_size("10gb"), Ok(10 << 30));
        assert_eq!(parse_size("1.5T"), Ok(1536 << 30));
        assert_eq!(parse_size(" 512 KiB "), Ok(512 << 10));
    }

    #[test]
    fn a_size_that_makes_no_sense_says_so() {
        for text in ["", "lots", "10 furlongs", "-5", "1e400G"] {
            assert!(parse_size(text).is_err(), "{text:?} was accepted");
        }
    }

    #[test]
    fn the_config_file_takes_a_size_either_way() {
        let file: ConfigFile = toml::from_str("minimum_free_space = \"20 GiB\"").unwrap();
        assert_eq!(file.minimum_free_space, Some(20 << 30));

        let file: ConfigFile = toml::from_str("minimum_free_space = 4096").unwrap();
        assert_eq!(file.minimum_free_space, Some(4096));

        let file: ConfigFile = toml::from_str("maximum_store_size = \"1.5T\"").unwrap();
        assert_eq!(file.maximum_store_size, Some(1536 << 30));

        let file: ConfigFile = toml::from_str("").unwrap();
        assert_eq!(file.minimum_free_space, None);

        assert!(toml::from_str::<ConfigFile>("minimum_free_space = \"soon\"").is_err());
        assert!(toml::from_str::<ConfigFile>("minimum_free_space = -1").is_err());
    }
}