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, /// Glob patterns excluded from every backup; see `beeping --help` /// (or [`compile_exclude`]) for the matching rules. #[serde(default)] pub exclude: Vec, /// 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, /// 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, } impl ConfigFile { pub fn load() -> Result { 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, 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::::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 { 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> { 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, config: &ConfigFile) -> Result { 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 { 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 { 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 { 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 { 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 { 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 { 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::("minimum_free_space = \"soon\"").is_err()); assert!(toml::from_str::("minimum_free_space = -1").is_err()); } }