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)]
12struct CredFile {
13    default_server: Option<String>,
14    servers: BTreeMap<String, ServerCred>,
15}
16
17impl<'de> Deserialize<'de> for CredFile {
18    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
19    where
20        D: serde::Deserializer<'de>,
21    {
22        #[derive(Deserialize)]
23        struct Raw {
24            default_server: Option<String>,
25            #[serde(default)]
26            servers: BTreeMap<String, ServerCredRepr>,
27        }
28
29        let raw = Raw::deserialize(deserializer)?;
30        let mut f = CredFile {
31            default_server: raw.default_server,
32            servers: BTreeMap::new(),
33        };
34        for (server, cred) in raw.servers {
35            // An old entry with no key is not a stored login. Drop it rather than keep
36            // an entry that would count as stored but could never produce a key.
37            if let Ok(cred) = ServerCred::try_from(cred) {
38                f.servers.insert(server, cred);
39            }
40        }
41        f.reconcile_default();
42        Ok(f)
43    }
44}
45
46impl CredFile {
47    fn insert(&mut self, server: &str, cred: ServerCred) {
48        self.servers.insert(server.to_string(), cred);
49        self.default_server = Some(server.to_string());
50    }
51
52    fn remove(&mut self, server: &str) -> Option<ServerCred> {
53        let removed = self.servers.remove(server);
54        self.reconcile_default();
55        removed
56    }
57
58    /// Keep the default on a server that exists, and on one whenever any server exists.
59    fn reconcile_default(&mut self) {
60        if self
61            .default_server
62            .as_ref()
63            .is_none_or(|d| !self.servers.contains_key(d))
64        {
65            self.default_server = self.servers.keys().next().cloned();
66        }
67    }
68}
69
70/// Where one server's key lives. The two wrong combinations of the old fields cannot be
71/// built: a file key always has its key, and a keyring entry never carries a stale copy.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(try_from = "ServerCredRepr", into = "ServerCredRepr")]
74enum ServerCred {
75    File {
76        key: String,
77        key_name: Option<String>,
78    },
79    Keyring {
80        key_name: Option<String>,
81    },
82}
83
84impl ServerCred {
85    fn key_name(&self) -> Option<&str> {
86        match self {
87            ServerCred::File { key_name, .. } | ServerCred::Keyring { key_name } => {
88                key_name.as_deref()
89            }
90        }
91    }
92}
93
94/// The `credentials.toml` shape. It stays compatible with files written before the enum.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96struct ServerCredRepr {
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    api_key: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    key_name: Option<String>,
101    #[serde(default)]
102    in_keyring: bool,
103}
104
105impl TryFrom<ServerCredRepr> for ServerCred {
106    type Error = &'static str;
107
108    fn try_from(repr: ServerCredRepr) -> Result<Self, Self::Error> {
109        if repr.in_keyring {
110            // A plaintext key beside `in_keyring = true` was never read.
111            Ok(ServerCred::Keyring {
112                key_name: repr.key_name,
113            })
114        } else if let Some(key) = repr.api_key {
115            Ok(ServerCred::File {
116                key,
117                key_name: repr.key_name,
118            })
119        } else {
120            Err("a file credential has no key")
121        }
122    }
123}
124
125impl From<ServerCred> for ServerCredRepr {
126    fn from(cred: ServerCred) -> Self {
127        match cred {
128            ServerCred::File { key, key_name } => ServerCredRepr {
129                api_key: Some(key),
130                key_name,
131                in_keyring: false,
132            },
133            ServerCred::Keyring { key_name } => ServerCredRepr {
134                api_key: None,
135                key_name,
136                in_keyring: true,
137            },
138        }
139    }
140}
141
142/// Where a stored key ended up.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum Stored {
145    Keyring,
146    File,
147}
148
149pub struct Store {
150    dir: PathBuf,
151    use_keyring: bool,
152}
153
154impl Store {
155    pub fn new(dir: &Path, use_keyring: bool) -> Store {
156        Store {
157            dir: dir.to_path_buf(),
158            use_keyring: use_keyring && keyring_supported(),
159        }
160    }
161
162    pub fn path(&self) -> PathBuf {
163        self.dir.join("credentials.toml")
164    }
165
166    fn read(&self) -> CredFile {
167        std::fs::read_to_string(self.path())
168            .ok()
169            .and_then(|t| toml::from_str(&t).ok())
170            .unwrap_or_default()
171    }
172
173    fn write(&self, f: &CredFile) -> anyhow::Result<()> {
174        std::fs::create_dir_all(&self.dir)
175            .with_context(|| format!("creating {}", self.dir.display()))?;
176        let text = toml::to_string_pretty(f)?;
177        write_private(&self.path(), text.as_bytes())
178    }
179
180    pub fn default_server(&self) -> Option<String> {
181        self.read().default_server
182    }
183
184    /// The stored key for `server` (an origin like `https://semora.example.com`).
185    pub fn get(&self, server: &str) -> Option<String> {
186        let f = self.read();
187        match f.servers.get(server)? {
188            ServerCred::File { key, .. } => Some(key.clone()),
189            ServerCred::Keyring { .. } => keyring_get(server),
190        }
191    }
192
193    pub fn key_name(&self, server: &str) -> Option<String> {
194        self.read()
195            .servers
196            .get(server)
197            .and_then(|c| c.key_name().map(str::to_string))
198    }
199
200    pub fn set(&self, server: &str, key: &str, key_name: &str) -> anyhow::Result<Stored> {
201        let mut f = self.read();
202        let in_keyring = self.use_keyring && keyring_set(server, key).is_ok();
203        // Whatever is stored here now is the only copy. A keyring entry left from an
204        // earlier login would hold the old key, and nothing would read or remove it again.
205        if !in_keyring {
206            keyring_delete(server);
207        }
208        let key_name = Some(key_name.to_string());
209        let cred = if in_keyring {
210            ServerCred::Keyring { key_name }
211        } else {
212            ServerCred::File {
213                key: key.to_string(),
214                key_name,
215            }
216        };
217        f.insert(server, cred);
218        self.write(&f)?;
219        Ok(if in_keyring {
220            Stored::Keyring
221        } else {
222            Stored::File
223        })
224    }
225
226    pub fn remove(&self, server: &str) -> anyhow::Result<()> {
227        let mut f = self.read();
228        if matches!(f.remove(server), Some(ServerCred::Keyring { .. })) {
229            keyring_delete(server);
230        }
231        self.write(&f)
232    }
233}
234
235fn write_private(path: &Path, data: &[u8]) -> anyhow::Result<()> {
236    #[cfg(unix)]
237    {
238        use std::io::Write;
239        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
240        let mut f = std::fs::OpenOptions::new()
241            .write(true)
242            .create(true)
243            .truncate(true)
244            .mode(0o600)
245            .open(path)?;
246        f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
247        f.write_all(data)?;
248        Ok(())
249    }
250    #[cfg(not(unix))]
251    {
252        std::fs::write(path, data).with_context(|| format!("writing {}", path.display()))
253    }
254}
255
256#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
257fn keyring_supported() -> bool {
258    true
259}
260
261#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
262fn keyring_supported() -> bool {
263    false
264}
265
266#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
267fn keyring_get(server: &str) -> Option<String> {
268    keyring::Entry::new(SERVICE, server)
269        .ok()?
270        .get_password()
271        .ok()
272}
273
274#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
275fn keyring_set(server: &str, key: &str) -> anyhow::Result<()> {
276    keyring::Entry::new(SERVICE, server)?.set_password(key)?;
277    Ok(())
278}
279
280#[cfg(all(feature = "keyring", any(windows, target_os = "macos")))]
281fn keyring_delete(server: &str) {
282    if let Ok(e) = keyring::Entry::new(SERVICE, server) {
283        let _ = e.delete_credential();
284    }
285}
286
287#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
288fn keyring_get(_server: &str) -> Option<String> {
289    let _ = SERVICE;
290    None
291}
292
293#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
294fn keyring_set(_server: &str, _key: &str) -> anyhow::Result<()> {
295    anyhow::bail!("no keyring on this platform")
296}
297
298#[cfg(not(all(feature = "keyring", any(windows, target_os = "macos"))))]
299fn keyring_delete(_server: &str) {}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn file_store_round_trip() {
307        let dir = tempfile::tempdir().unwrap();
308        let s = Store::new(dir.path(), false);
309        assert_eq!(s.get("https://a.example"), None);
310        assert_eq!(
311            s.set("https://a.example", "sm_x", "cli@host").unwrap(),
312            Stored::File
313        );
314        assert_eq!(s.get("https://a.example").as_deref(), Some("sm_x"));
315        assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
316        assert_eq!(s.key_name("https://a.example").as_deref(), Some("cli@host"));
317        s.remove("https://a.example").unwrap();
318        assert_eq!(s.get("https://a.example"), None);
319        assert_eq!(s.default_server(), None);
320    }
321
322    #[cfg(unix)]
323    #[test]
324    fn credentials_file_is_private() {
325        use std::os::unix::fs::PermissionsExt;
326        let dir = tempfile::tempdir().unwrap();
327        let s = Store::new(dir.path(), false);
328        s.set("https://a.example", "sm_x", "n").unwrap();
329        let mode = std::fs::metadata(s.path()).unwrap().permissions().mode() & 0o777;
330        assert_eq!(mode, 0o600);
331    }
332
333    #[test]
334    fn the_old_file_shape_still_loads() {
335        let dir = tempfile::tempdir().unwrap();
336        std::fs::write(
337            dir.path().join("credentials.toml"),
338            r#"default_server = "https://a.example"
339
340[servers."https://a.example"]
341api_key = "sm_x"
342key_name = "cli@host"
343in_keyring = false
344"#,
345        )
346        .unwrap();
347        let s = Store::new(dir.path(), false);
348        assert_eq!(s.get("https://a.example").as_deref(), Some("sm_x"));
349        assert_eq!(s.key_name("https://a.example").as_deref(), Some("cli@host"));
350        assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
351        s.set("https://b.example", "sm_y", "cli@host").unwrap();
352        let text = std::fs::read_to_string(s.path()).unwrap();
353        assert!(text.contains("api_key = \"sm_y\""), "{text}");
354        assert!(text.contains("in_keyring = false"), "{text}");
355    }
356
357    #[test]
358    fn an_old_bad_entry_is_dropped_or_repaired() {
359        let text = r#"default_server = "https://missing.example"
360
361[servers."https://no-key.example"]
362key_name = "n"
363in_keyring = false
364
365[servers."https://stale.example"]
366api_key = "sm_stale"
367key_name = "n"
368in_keyring = true
369"#;
370        let f: CredFile = toml::from_str(text).unwrap();
371        assert!(
372            !f.servers.contains_key("https://no-key.example"),
373            "an entry with no key is not a stored login"
374        );
375        assert_eq!(
376            f.servers.get("https://stale.example"),
377            Some(&ServerCred::Keyring {
378                key_name: Some("n".into()),
379            }),
380            "a keyring entry ignores a stale plaintext copy"
381        );
382        assert_eq!(
383            f.default_server.as_deref(),
384            Some("https://stale.example"),
385            "the default moves to a server that exists"
386        );
387        let text = toml::to_string(&f).unwrap();
388        assert!(!text.contains("api_key"), "the stale copy is gone: {text}");
389    }
390
391    #[test]
392    fn default_server_follows_the_servers() {
393        let dir = tempfile::tempdir().unwrap();
394        let s = Store::new(dir.path(), false);
395        s.set("https://a.example", "sm_a", "a").unwrap();
396        s.set("https://b.example", "sm_b", "b").unwrap();
397        assert_eq!(s.default_server().as_deref(), Some("https://b.example"));
398        s.remove("https://b.example").unwrap();
399        assert_eq!(s.default_server().as_deref(), Some("https://a.example"));
400        s.remove("https://a.example").unwrap();
401        assert_eq!(s.default_server(), None);
402    }
403}