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