Skip to main content

dvm_run/
credentials.rs

1//! The API keys `dvm-run login` saved, by the address of the dvm.run each is
2//! for: `dvm/credentials.json` in `$XDG_CONFIG_HOME`, else in `~/.config`,
3//! readable by its user alone. The Python and JS SDKs read and write the
4//! same file.
5
6use 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
20/// Where the keys are saved.
21pub 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
29/// The key saved for the dvm.run at `api_url`, if there's one.
30pub(crate) fn saved_key(api_url: &str) -> Result<Option<String>> {
31    saved_key_in(&credentials_path(), api_url)
32}
33
34/// Saves `key` for the dvm.run at `api_url`, in place of the one saved for
35/// it before; the file it's in.
36pub 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
75/// Written beside it, then moved over it, so a reader never sees half of
76/// it; made for its user alone before the key is in it.
77fn 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        // As Python's json.dump(indent=2) writes it.
153        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}