use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use toml_edit::DocumentMut;
use super::name::ProfileName;
use super::paths::ConfigDir;
use super::private_fs;
use super::profile::{GlobalConfig, ParseError, Profile};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StoreError {
#[error("{}: {source}", path.display())]
Io {
path: PathBuf,
source: io::Error,
},
#[error("{}: {source}", path.display())]
Parse {
path: PathBuf,
source: ParseError,
},
#[error(
"{}: not a valid profile name; rename it (lower-case letters, digits, `-`, `_`) or move it out of the profiles directory",
.0.display()
)]
BadFileName(PathBuf),
#[error("profile `{0}` already exists")]
AlreadyExists(ProfileName),
#[error("profile `{0}` doesn't exist; see `gitaway profile list`")]
NotFound(ProfileName),
#[error("not saved, the result would be invalid: {0}")]
InvalidEdit(ParseError),
}
#[derive(Debug, Clone)]
pub struct Store {
dir: ConfigDir,
}
impl Store {
pub fn new(dir: ConfigDir) -> Self {
Self { dir }
}
pub fn dir(&self) -> &ConfigDir {
&self.dir
}
pub fn load_global(&self) -> Result<GlobalConfig, StoreError> {
let path = self.dir.config_file();
match read_optional(&path)? {
Some(text) => {
GlobalConfig::from_toml(&text).map_err(|source| StoreError::Parse { path, source })
}
None => Ok(GlobalConfig::default()),
}
}
pub fn update_global(
&self,
edit: impl FnOnce(&mut DocumentMut) -> Result<(), String>,
) -> Result<(), StoreError> {
let path = self.dir.config_file();
let text = read_optional(&path)?.unwrap_or_default();
let new_text = apply_edit(&path, &text, edit)?;
GlobalConfig::from_toml(&new_text).map_err(StoreError::InvalidEdit)?;
write_atomic(&path, &new_text)
}
pub fn load_profiles(&self) -> Result<BTreeMap<ProfileName, Profile>, StoreError> {
let dir = self.dir.profiles_dir();
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(source) => return Err(StoreError::Io { path: dir, source }),
};
let mut profiles = BTreeMap::new();
for entry in entries {
let path = entry
.map_err(|source| StoreError::Io {
path: dir.clone(),
source,
})?
.path();
let Some(stem) = profile_stem(&path) else {
continue;
};
let name: ProfileName = stem
.parse()
.map_err(|_| StoreError::BadFileName(path.clone()))?;
let profile = self.load_profile(&name)?;
profiles.insert(name, profile);
}
Ok(profiles)
}
pub fn load_profile(&self, name: &ProfileName) -> Result<Profile, StoreError> {
let path = self.dir.profile_file(name);
let text = read_optional(&path)?.ok_or_else(|| StoreError::NotFound(name.clone()))?;
Profile::from_toml(&text).map_err(|source| StoreError::Parse { path, source })
}
pub fn profile_exists(&self, name: &ProfileName) -> bool {
self.dir.profile_file(name).is_file()
}
pub fn create_profile(&self, name: &ProfileName, text: &str) -> Result<(), StoreError> {
if self.profile_exists(name) {
return Err(StoreError::AlreadyExists(name.clone()));
}
Profile::from_toml(text).map_err(StoreError::InvalidEdit)?;
write_atomic(&self.dir.profile_file(name), text)
}
pub fn update_profile(
&self,
name: &ProfileName,
edit: impl FnOnce(&mut DocumentMut) -> Result<(), String>,
) -> Result<(), StoreError> {
let path = self.dir.profile_file(name);
let text = read_optional(&path)?.ok_or_else(|| StoreError::NotFound(name.clone()))?;
let new_text = apply_edit(&path, &text, edit)?;
Profile::from_toml(&new_text).map_err(StoreError::InvalidEdit)?;
write_atomic(&path, &new_text)
}
pub fn remove_profile(&self, name: &ProfileName) -> Result<(), StoreError> {
let path = self.dir.profile_file(name);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
Err(StoreError::NotFound(name.clone()))
}
Err(source) => Err(StoreError::Io { path, source }),
}
}
}
fn profile_stem(path: &Path) -> Option<&str> {
let file_name = path.file_name()?.to_str()?;
if file_name.starts_with('.') {
return None;
}
file_name.strip_suffix(".toml")
}
fn apply_edit(
path: &Path,
text: &str,
edit: impl FnOnce(&mut DocumentMut) -> Result<(), String>,
) -> Result<String, StoreError> {
let mut doc: DocumentMut =
text.parse()
.map_err(|e: toml_edit::TomlError| StoreError::Parse {
path: path.to_owned(),
source: ParseError::Invalid(e.to_string()),
})?;
edit(&mut doc).map_err(|e| StoreError::InvalidEdit(ParseError::Invalid(e)))?;
Ok(doc.to_string())
}
fn read_optional(path: &Path) -> Result<Option<String>, StoreError> {
match fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(StoreError::Io {
path: path.to_owned(),
source,
}),
}
}
fn write_atomic(path: &Path, text: &str) -> Result<(), StoreError> {
private_fs::write_atomic(path, text.as_bytes()).map_err(|source| StoreError::Io {
path: path.to_owned(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
const PROFILE: &str = r#"# my comment
[transport.wireguard]
endpoint = "vpn.example.net:51820"
server_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
"#;
fn store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = ConfigDir::new(Some(tmp.path().into()), None).unwrap();
(tmp, Store::new(dir))
}
fn name(s: &str) -> ProfileName {
s.parse().unwrap()
}
#[test]
fn empty_config_dir_means_defaults_and_no_profiles() {
let (_tmp, store) = store();
assert_eq!(store.load_global().unwrap(), GlobalConfig::default());
assert!(store.load_profiles().unwrap().is_empty());
}
#[test]
fn created_profiles_can_be_loaded() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
store.create_profile(&name("office"), PROFILE).unwrap();
let profiles = store.load_profiles().unwrap();
let names: Vec<_> = profiles.keys().map(ProfileName::as_str).collect();
assert_eq!(names, ["home", "office"]);
assert!(store.profile_exists(&name("home")));
}
#[test]
fn creating_an_existing_or_invalid_profile_fails() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
assert!(matches!(
store.create_profile(&name("home"), PROFILE),
Err(StoreError::AlreadyExists(_))
));
assert!(matches!(
store.create_profile(&name("bad"), "not = [valid"),
Err(StoreError::InvalidEdit(_))
));
assert!(!store.profile_exists(&name("bad")));
}
#[test]
fn valid_edits_keep_comments() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
store
.update_profile(&name("home"), |doc| {
doc["transport"]["wireguard"]["mtu"] = toml_edit::value(1420);
Ok(())
})
.unwrap();
let text = fs::read_to_string(store.dir().profile_file(&name("home"))).unwrap();
assert!(text.contains("# my comment"));
let profile = store.load_profile(&name("home")).unwrap();
let super::super::profile::Transport::WireGuard(wg) = profile.transport;
assert_eq!(wg.mtu, Some(1420));
}
#[test]
fn invalid_edits_change_nothing() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
let path = store.dir().profile_file(&name("home"));
let before = fs::read_to_string(&path).unwrap();
let result = store.update_profile(&name("home"), |doc| {
doc["transport"]["wireguard"]["endpoint"] = toml_edit::value("no port");
Ok(())
});
assert!(matches!(result, Err(StoreError::InvalidEdit(_))));
assert_eq!(fs::read_to_string(&path).unwrap(), before);
}
#[test]
fn failed_edits_change_nothing() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
let path = store.dir().profile_file(&name("home"));
let before = fs::read_to_string(&path).unwrap();
let result = store.update_profile(&name("home"), |doc| {
doc["transport"]["wireguard"]["mtu"] = toml_edit::value(1400);
Err("the edit gave up".to_owned())
});
assert!(matches!(result, Err(StoreError::InvalidEdit(_))));
assert_eq!(fs::read_to_string(&path).unwrap(), before);
}
#[test]
fn global_config_edits() {
let (_tmp, store) = store();
store
.update_global(|doc| {
doc["default_profile"] = toml_edit::value("home");
Ok(())
})
.unwrap();
assert_eq!(
store.load_global().unwrap().default_profile,
Some(name("home"))
);
let invalid = store.update_global(|doc| {
doc["default_profile"] = toml_edit::value("../x");
Ok(())
});
assert!(matches!(invalid, Err(StoreError::InvalidEdit(_))));
assert_eq!(
store.load_global().unwrap().default_profile,
Some(name("home"))
);
}
#[test]
fn removing_profiles() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
store.remove_profile(&name("home")).unwrap();
assert!(!store.profile_exists(&name("home")));
assert!(matches!(
store.remove_profile(&name("home")),
Err(StoreError::NotFound(_))
));
assert!(matches!(
store.load_profile(&name("home")),
Err(StoreError::NotFound(_))
));
}
#[test]
fn broken_profile_is_an_error_naming_the_file() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
fs::write(store.dir().profile_file(&name("office")), "transport = 1").unwrap();
let err = store.load_profiles().unwrap_err();
assert!(matches!(&err, StoreError::Parse { path, .. } if path.ends_with("office.toml")));
}
#[test]
fn badly_named_toml_file_is_an_error_but_other_files_are_ignored() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
let profiles = store.dir().profiles_dir();
fs::write(profiles.join("notes.txt"), "anything").unwrap();
fs::write(profiles.join(".home.toml.swp"), "anything").unwrap();
fs::write(profiles.join(".hidden.toml"), "anything").unwrap();
assert_eq!(store.load_profiles().unwrap().len(), 1);
fs::write(profiles.join("my home.toml"), PROFILE).unwrap();
assert!(matches!(
store.load_profiles(),
Err(StoreError::BadFileName(_))
));
}
#[test]
fn writes_leave_no_temporary_files() {
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
let _ = store.update_profile(&name("home"), |doc| {
doc["transport"]["wireguard"]["mtu"] = toml_edit::value(1400);
Ok(())
});
let files: Vec<_> = fs::read_dir(store.dir().profiles_dir())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(files, ["home.toml"]);
}
#[cfg(unix)]
#[test]
fn files_and_directories_are_private() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, store) = store();
store.create_profile(&name("home"), PROFILE).unwrap();
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&store.dir().profile_file(&name("home"))), 0o600);
assert_eq!(mode(&store.dir().profiles_dir()), 0o700);
}
}