semora_cli/
credentials.rs1use 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 api_key: Option<String>,
22 key_name: Option<String>,
23 #[serde(default)]
24 in_keyring: bool,
25}
26
27#[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 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 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}