Skip to main content

semora_cli/
credentials.rs

1//! Stored logins: the OS keyring (Windows, macOS) or `credentials.toml` (mode 600).
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::Context;
7use serde::{Deserialize, Serialize};
8
9const SERVICE: &str = "semora";
10
11#[derive(Debug, Default, Serialize, Deserialize)]
12struct CredFile {
13    default_server: Option<String>,
14    #[serde(default)]
15    servers: BTreeMap<String, ServerCred>,
16}
17
18#[derive(Debug, Default, Clone, Serialize, Deserialize)]
19struct ServerCred {
20    /// Present when the key lives in this file rather than the keyring.
21    api_key: Option<String>,
22    key_name: Option<String>,
23    #[serde(default)]
24    in_keyring: bool,
25}
26
27/// Where a stored key ended up.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Stored {
30    Keyring,
31    File,
32}
33
34pub struct Store {
35    dir: PathBuf,
36    use_keyring: bool,
37}
38
39impl Store {
40    pub fn new(dir: &Path, use_keyring: bool) -> Store {
41        Store {
42            dir: dir.to_path_buf(),
43            use_keyring: use_keyring && keyring_supported(),
44        }
45    }
46
47    pub fn path(&self) -> PathBuf {
48        self.dir.join("credentials.toml")
49    }
50
51    fn read(&self) -> CredFile {
52        std::fs::read_to_string(self.path())
53            .ok()
54            .and_then(|t| toml::from_str(&t).ok())
55            .unwrap_or_default()
56    }
57
58    fn write(&self, f: &CredFile) -> anyhow::Result<()> {
59        std::fs::create_dir_all(&self.dir)
60            .with_context(|| format!("creating {}", self.dir.display()))?;
61        let text = toml::to_string_pretty(f)?;
62        write_private(&self.path(), text.as_bytes())
63    }
64
65    pub fn default_server(&self) -> Option<String> {
66        self.read().default_server
67    }
68
69    /// The stored key for `server` (an origin like `https://semora.example.com`).
70    pub fn get(&self, server: &str) -> Option<String> {
71        let f = self.read();
72        let cred = f.servers.get(server)?;
73        if cred.in_keyring {
74            keyring_get(server)
75        } else {
76            cred.api_key.clone()
77        }
78    }
79
80    pub fn key_name(&self, server: &str) -> Option<String> {
81        self.read()
82            .servers
83            .get(server)
84            .and_then(|c| c.key_name.clone())
85    }
86
87    pub fn set(&self, server: &str, key: &str, key_name: &str) -> anyhow::Result<Stored> {
88        let mut f = self.read();
89        let in_keyring = self.use_keyring && keyring_set(server, key).is_ok();
90        // A key that now goes to the file replaces one that was in the keyring. That entry
91        // holds the old key, and nothing would read or remove it again.
92        if !in_keyring && f.servers.get(server).is_some_and(|old| old.in_keyring) {
93            keyring_delete(server);
94        }
95        f.servers.insert(
96            server.to_string(),
97            ServerCred {
98                api_key: (!in_keyring).then(|| key.to_string()),
99                key_name: Some(key_name.to_string()),
100                in_keyring,
101            },
102        );
103        f.default_server = Some(server.to_string());
104        self.write(&f)?;
105        Ok(if in_keyring {
106            Stored::Keyring
107        } else {
108            Stored::File
109        })
110    }
111
112    pub fn remove(&self, server: &str) -> anyhow::Result<()> {
113        let mut f = self.read();
114        if let Some(c) = f.servers.remove(server)
115            && c.in_keyring
116        {
117            keyring_delete(server);
118        }
119        if f.default_server.as_deref() == Some(server) {
120            f.default_server = f.servers.keys().next().cloned();
121        }
122        self.write(&f)
123    }
124}
125
126fn write_private(path: &Path, data: &[u8]) -> anyhow::Result<()> {
127    #[cfg(unix)]
128    {
129        use std::io::Write;
130        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
131        let mut f = std::fs::OpenOptions::new()
132            .write(true)
133            .create(true)
134            .truncate(true)
135            .mode(0o600)
136            .open(path)?;
137        f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
138        f.write_all(data)?;
139        Ok(())
140    }
141    #[cfg(not(unix))]
142    {
143        std::fs::write(path, data).with_context(|| format!("writing {}", path.display()))
144    }
145}
146
147#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
148fn keyring_supported() -> bool {
149    true
150}
151
152#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
153fn keyring_supported() -> bool {
154    false
155}
156
157#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
158fn keyring_get(server: &str) -> Option<String> {
159    keyring::Entry::new(SERVICE, server)
160        .ok()?
161        .get_password()
162        .ok()
163}
164
165#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
166fn keyring_set(server: &str, key: &str) -> anyhow::Result<()> {
167    keyring::Entry::new(SERVICE, server)?.set_password(key)?;
168    Ok(())
169}
170
171#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
172fn keyring_delete(server: &str) {
173    if let Ok(e) = keyring::Entry::new(SERVICE, server) {
174        let _ = e.delete_credential();
175    }
176}
177
178#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
179fn keyring_get(_server: &str) -> Option<String> {
180    let _ = SERVICE;
181    None
182}
183
184#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
185fn keyring_set(_server: &str, _key: &str) -> anyhow::Result<()> {
186    anyhow::bail!("no keyring on this platform")
187}
188
189#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
190fn keyring_delete(_server: &str) {}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn file_store_round_trip() {
198        let dir = tempfile::tempdir().unwrap();
199        let s = Store::new(dir.path(), false);
200        assert_eq!(s.get("https://a.example"), None);
201        assert_eq!(
202            s.set("https://a.example", "sm_x", "cli@host").unwrap(),
203            Stored::File
204        );
205        assert_eq!(s.get("https://a.example").as_deref(), Some("sm_x"));
206        assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
207        assert_eq!(s.key_name("https://a.example").as_deref(), Some("cli@host"));
208        s.remove("https://a.example").unwrap();
209        assert_eq!(s.get("https://a.example"), None);
210        assert_eq!(s.default_server(), None);
211    }
212
213    #[cfg(unix)]
214    #[test]
215    fn credentials_file_is_private() {
216        use std::os::unix::fs::PermissionsExt;
217        let dir = tempfile::tempdir().unwrap();
218        let s = Store::new(dir.path(), false);
219        s.set("https://a.example", "sm_x", "n").unwrap();
220        let mode = std::fs::metadata(s.path()).unwrap().permissions().mode() & 0o777;
221        assert_eq!(mode, 0o600);
222    }
223}