use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Default, Serialize, Deserialize)]
struct SavedKeys {
keys: BTreeMap<String, String>,
}
pub fn credentials_path() -> PathBuf {
let config = match std::env::var_os("XDG_CONFIG_HOME") {
Some(config) if !config.is_empty() => PathBuf::from(config),
_ => home().join(".config"),
};
config.join("dvm").join("credentials.json")
}
pub(crate) fn saved_key(api_url: &str) -> Result<Option<String>> {
saved_key_in(&credentials_path(), api_url)
}
pub fn save_key(api_url: &str, key: &str) -> Result<PathBuf> {
save_key_in(credentials_path(), api_url, key)
}
fn saved_key_in(path: &Path, api_url: &str) -> Result<Option<String>> {
Ok(saved_keys(path)?.keys.remove(address_of(api_url)))
}
fn save_key_in(path: PathBuf, api_url: &str, key: &str) -> Result<PathBuf> {
let mut saved = saved_keys(&path)?;
saved
.keys
.insert(address_of(api_url).to_owned(), key.to_owned());
let mut text = serde_json::to_string_pretty(&saved).expect("keys are JSON");
text.push('\n');
write_privately(&path, text.as_bytes()).map_err(|error| Error::Credentials {
path: path.clone(),
problem: error.to_string(),
})?;
Ok(path)
}
fn saved_keys(path: &Path) -> Result<SavedKeys> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(SavedKeys::default()),
Err(error) => {
return Err(Error::Credentials {
path: path.to_owned(),
problem: error.to_string(),
});
}
};
serde_json::from_str(&text).map_err(|_| Error::Credentials {
path: path.to_owned(),
problem: "isn't what `dvm-run login` writes: delete it, and log in again".to_owned(),
})
}
fn write_privately(path: &Path, contents: &[u8]) -> io::Result<()> {
let directory = path.parent().expect("the file is in a directory");
let written = path.with_extension("json.new");
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(directory)?;
options.mode(0o600);
}
#[cfg(not(unix))]
fs::create_dir_all(directory)?;
match fs::remove_file(&written) {
Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
_ => {}
}
let mut file = options.open(&written)?;
file.write_all(contents)?;
file.sync_all()?;
fs::rename(&written, path)
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_default()
}
fn address_of(api_url: &str) -> &str {
api_url.trim_end_matches('/')
}
#[cfg(test)]
mod tests {
use super::*;
const URL: &str = "http://dvm.test";
#[test]
fn a_key_is_saved_for_its_user_alone() {
let config = tempfile::tempdir().unwrap();
let path = config.path().join("dvm/credentials.json");
save_key_in(path.clone(), &format!("{URL}/"), "dvm_a").unwrap();
assert_eq!(saved_key_in(&path, URL).unwrap().as_deref(), Some("dvm_a"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
}
}
#[test]
fn each_dvm_run_keeps_its_own_key() {
let config = tempfile::tempdir().unwrap();
let path = config.path().join("dvm/credentials.json");
save_key_in(path.clone(), URL, "dvm_a").unwrap();
save_key_in(path.clone(), "http://other.test", "dvm_b").unwrap();
save_key_in(path.clone(), URL, "dvm_c").unwrap();
assert_eq!(saved_key_in(&path, URL).unwrap().as_deref(), Some("dvm_c"));
let other = saved_key_in(&path, "http://other.test").unwrap();
assert_eq!(other.as_deref(), Some("dvm_b"));
assert_eq!(saved_key_in(&path, "http://third.test").unwrap(), None);
}
#[test]
fn the_python_sdks_file_is_the_same() {
let config = tempfile::tempdir().unwrap();
let path = config.path().join("credentials.json");
fs::write(
&path,
format!("{{\n \"keys\": {{\n \"{URL}\": \"dvm_python\"\n }}\n}}\n"),
)
.unwrap();
assert_eq!(
saved_key_in(&path, URL).unwrap().as_deref(),
Some("dvm_python")
);
}
#[test]
fn a_file_it_didnt_write_is_refused() {
let config = tempfile::tempdir().unwrap();
let path = config.path().join("credentials.json");
fs::write(&path, "not json").unwrap();
let refused = saved_key_in(&path, URL).unwrap_err();
assert!(
refused.to_string().contains("delete it, and log in again"),
"{refused}"
);
}
}