1use 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 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 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#[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#[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 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#[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 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 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}