1use std::collections::BTreeMap;
7use std::fs;
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14
15#[derive(Default, Serialize, Deserialize)]
16struct SavedKeys {
17 keys: BTreeMap<String, String>,
18}
19
20pub fn credentials_path() -> PathBuf {
22 let config = match std::env::var_os("XDG_CONFIG_HOME") {
23 Some(config) if !config.is_empty() => PathBuf::from(config),
24 _ => home().join(".config"),
25 };
26 config.join("dvm").join("credentials.json")
27}
28
29pub(crate) fn saved_key(api_url: &str) -> Result<Option<String>> {
31 saved_key_in(&credentials_path(), api_url)
32}
33
34pub fn save_key(api_url: &str, key: &str) -> Result<PathBuf> {
37 save_key_in(credentials_path(), api_url, key)
38}
39
40fn saved_key_in(path: &Path, api_url: &str) -> Result<Option<String>> {
41 Ok(saved_keys(path)?.keys.remove(address_of(api_url)))
42}
43
44fn save_key_in(path: PathBuf, api_url: &str, key: &str) -> Result<PathBuf> {
45 let mut saved = saved_keys(&path)?;
46 saved
47 .keys
48 .insert(address_of(api_url).to_owned(), key.to_owned());
49 let mut text = serde_json::to_string_pretty(&saved).expect("keys are JSON");
50 text.push('\n');
51 write_privately(&path, text.as_bytes()).map_err(|error| Error::Credentials {
52 path: path.clone(),
53 problem: error.to_string(),
54 })?;
55 Ok(path)
56}
57
58fn saved_keys(path: &Path) -> Result<SavedKeys> {
59 let text = match fs::read_to_string(path) {
60 Ok(text) => text,
61 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(SavedKeys::default()),
62 Err(error) => {
63 return Err(Error::Credentials {
64 path: path.to_owned(),
65 problem: error.to_string(),
66 });
67 }
68 };
69 serde_json::from_str(&text).map_err(|_| Error::Credentials {
70 path: path.to_owned(),
71 problem: "isn't what `dvm-run login` writes: delete it, and log in again".to_owned(),
72 })
73}
74
75fn write_privately(path: &Path, contents: &[u8]) -> io::Result<()> {
78 let directory = path.parent().expect("the file is in a directory");
79 let written = path.with_extension("json.new");
80 let mut options = fs::OpenOptions::new();
81 options.write(true).create_new(true);
82 #[cfg(unix)]
83 {
84 use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
85 fs::DirBuilder::new()
86 .recursive(true)
87 .mode(0o700)
88 .create(directory)?;
89 options.mode(0o600);
90 }
91 #[cfg(not(unix))]
92 fs::create_dir_all(directory)?;
93 match fs::remove_file(&written) {
94 Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
95 _ => {}
96 }
97 let mut file = options.open(&written)?;
98 file.write_all(contents)?;
99 file.sync_all()?;
100 fs::rename(&written, path)
101}
102
103fn home() -> PathBuf {
104 std::env::var_os("HOME")
105 .or_else(|| std::env::var_os("USERPROFILE"))
106 .map(PathBuf::from)
107 .unwrap_or_default()
108}
109
110fn address_of(api_url: &str) -> &str {
111 api_url.trim_end_matches('/')
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 const URL: &str = "http://dvm.test";
119
120 #[test]
121 fn a_key_is_saved_for_its_user_alone() {
122 let config = tempfile::tempdir().unwrap();
123 let path = config.path().join("dvm/credentials.json");
124 save_key_in(path.clone(), &format!("{URL}/"), "dvm_a").unwrap();
125 assert_eq!(saved_key_in(&path, URL).unwrap().as_deref(), Some("dvm_a"));
126 #[cfg(unix)]
127 {
128 use std::os::unix::fs::PermissionsExt;
129 let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
130 assert_eq!(mode(&path), 0o600);
131 assert_eq!(mode(path.parent().unwrap()), 0o700);
132 }
133 }
134
135 #[test]
136 fn each_dvm_run_keeps_its_own_key() {
137 let config = tempfile::tempdir().unwrap();
138 let path = config.path().join("dvm/credentials.json");
139 save_key_in(path.clone(), URL, "dvm_a").unwrap();
140 save_key_in(path.clone(), "http://other.test", "dvm_b").unwrap();
141 save_key_in(path.clone(), URL, "dvm_c").unwrap();
142 assert_eq!(saved_key_in(&path, URL).unwrap().as_deref(), Some("dvm_c"));
143 let other = saved_key_in(&path, "http://other.test").unwrap();
144 assert_eq!(other.as_deref(), Some("dvm_b"));
145 assert_eq!(saved_key_in(&path, "http://third.test").unwrap(), None);
146 }
147
148 #[test]
149 fn the_python_sdks_file_is_the_same() {
150 let config = tempfile::tempdir().unwrap();
151 let path = config.path().join("credentials.json");
152 fs::write(
154 &path,
155 format!("{{\n \"keys\": {{\n \"{URL}\": \"dvm_python\"\n }}\n}}\n"),
156 )
157 .unwrap();
158 assert_eq!(
159 saved_key_in(&path, URL).unwrap().as_deref(),
160 Some("dvm_python")
161 );
162 }
163
164 #[test]
165 fn a_file_it_didnt_write_is_refused() {
166 let config = tempfile::tempdir().unwrap();
167 let path = config.path().join("credentials.json");
168 fs::write(&path, "not json").unwrap();
169 let refused = saved_key_in(&path, URL).unwrap_err();
170 assert!(
171 refused.to_string().contains("delete it, and log in again"),
172 "{refused}"
173 );
174 }
175}