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        f.servers.insert(
91            server.to_string(),
92            ServerCred {
93                api_key: (!in_keyring).then(|| key.to_string()),
94                key_name: Some(key_name.to_string()),
95                in_keyring,
96            },
97        );
98        f.default_server = Some(server.to_string());
99        self.write(&f)?;
100        Ok(if in_keyring {
101            Stored::Keyring
102        } else {
103            Stored::File
104        })
105    }
106
107    pub fn remove(&self, server: &str) -> anyhow::Result<()> {
108        let mut f = self.read();
109        if let Some(c) = f.servers.remove(server)
110            && c.in_keyring
111        {
112            keyring_delete(server);
113        }
114        if f.default_server.as_deref() == Some(server) {
115            f.default_server = f.servers.keys().next().cloned();
116        }
117        self.write(&f)
118    }
119}
120
121fn write_private(path: &Path, data: &[u8]) -> anyhow::Result<()> {
122    #[cfg(unix)]
123    {
124        use std::io::Write;
125        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
126        let mut f = std::fs::OpenOptions::new()
127            .write(true)
128            .create(true)
129            .truncate(true)
130            .mode(0o600)
131            .open(path)?;
132        f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
133        f.write_all(data)?;
134        Ok(())
135    }
136    #[cfg(not(unix))]
137    {
138        std::fs::write(path, data).with_context(|| format!("writing {}", path.display()))
139    }
140}
141
142#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
143fn keyring_supported() -> bool {
144    true
145}
146
147#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
148fn keyring_supported() -> bool {
149    false
150}
151
152#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
153fn keyring_get(server: &str) -> Option<String> {
154    keyring::Entry::new(SERVICE, server)
155        .ok()?
156        .get_password()
157        .ok()
158}
159
160#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
161fn keyring_set(server: &str, key: &str) -> anyhow::Result<()> {
162    keyring::Entry::new(SERVICE, server)?.set_password(key)?;
163    Ok(())
164}
165
166#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
167fn keyring_delete(server: &str) {
168    if let Ok(e) = keyring::Entry::new(SERVICE, server) {
169        let _ = e.delete_credential();
170    }
171}
172
173#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
174fn keyring_get(_server: &str) -> Option<String> {
175    let _ = SERVICE;
176    None
177}
178
179#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
180fn keyring_set(_server: &str, _key: &str) -> anyhow::Result<()> {
181    anyhow::bail!("no keyring on this platform")
182}
183
184#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
185fn keyring_delete(_server: &str) {}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn file_store_round_trip() {
193        let dir = tempfile::tempdir().unwrap();
194        let s = Store::new(dir.path(), false);
195        assert_eq!(s.get("https://a.example"), None);
196        assert_eq!(
197            s.set("https://a.example", "sm_x", "cli@host").unwrap(),
198            Stored::File
199        );
200        assert_eq!(s.get("https://a.example").as_deref(), Some("sm_x"));
201        assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
202        assert_eq!(s.key_name("https://a.example").as_deref(), Some("cli@host"));
203        s.remove("https://a.example").unwrap();
204        assert_eq!(s.get("https://a.example"), None);
205        assert_eq!(s.default_server(), None);
206    }
207
208    #[cfg(unix)]
209    #[test]
210    fn credentials_file_is_private() {
211        use std::os::unix::fs::PermissionsExt;
212        let dir = tempfile::tempdir().unwrap();
213        let s = Store::new(dir.path(), false);
214        s.set("https://a.example", "sm_x", "n").unwrap();
215        let mode = std::fs::metadata(s.path()).unwrap().permissions().mode() & 0o777;
216        assert_eq!(mode, 0o600);
217    }
218}