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 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}