1use super::{ConfigError, ConfigPaths};
2use keyring::{Entry, Error as KeyringError};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::env;
6use std::fs;
7use std::io::{Read, Write};
8use std::num::NonZeroU32;
9use std::path::{Path, PathBuf};
10
11const KEYRING_SERVICE: &str = "plausible-cli";
12
13fn keyring_enabled() -> bool {
14 if let Ok(value) = env::var("PLAUSIBLE_CLI_DISABLE_KEYRING") {
15 let v = value.to_lowercase();
16 return !(v == "1" || v == "true");
17 }
18 if let Ok(ci) = env::var("CI") {
19 let ci = ci.to_lowercase();
20 if ci == "1" || ci == "true" {
21 return false;
22 }
23 }
24 true
25}
26
27fn keyring_entry(alias: &str) -> Result<Entry, KeyringError> {
28 Entry::new(KEYRING_SERVICE, alias)
29}
30
31fn store_keyring_secret(alias: &str, secret: &str) -> Result<(), KeyringError> {
32 keyring_entry(alias)?.set_password(secret)
33}
34
35fn read_keyring_secret(alias: &str) -> Result<String, KeyringError> {
36 keyring_entry(alias)?.get_password()
37}
38
39fn delete_keyring_secret(alias: &str) -> Result<(), KeyringError> {
40 match keyring_entry(alias)?.delete_password() {
41 Err(KeyringError::NoEntry) => Ok(()),
42 other => other,
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct AccountStore {
49 paths: ConfigPaths,
50}
51
52impl AccountStore {
53 pub fn new(paths: ConfigPaths) -> Result<Self, AccountStoreError> {
55 paths.ensure_exists()?;
56 Ok(Self { paths })
57 }
58
59 pub fn add_account(
61 &self,
62 alias: &str,
63 api_key: &str,
64 profile: AccountProfile,
65 ) -> Result<(), AccountStoreError> {
66 validate_alias(alias)?;
67 if api_key.trim().is_empty() {
68 return Err(AccountStoreError::EmptyApiKey);
69 }
70 let mut state = self.read_state()?;
71 if state.accounts.contains_key(alias) {
72 return Err(AccountStoreError::DuplicateAlias(alias.to_string()));
73 }
74 state.accounts.insert(alias.to_string(), profile);
75 state.settings.entry(alias.to_string()).or_default();
76 if state.default_account.is_none() {
77 state.default_account = Some(alias.to_string());
78 }
79 self.write_state(&state)?;
80 self.write_secret(alias, api_key)?;
81 Ok(())
82 }
83
84 pub fn get_account(&self, alias: &str) -> Result<AccountRecord, AccountStoreError> {
86 let state = self.read_state()?;
87 let profile = state
88 .accounts
89 .get(alias)
90 .cloned()
91 .ok_or_else(|| AccountStoreError::AccountNotFound(alias.to_string()))?;
92 let api_key = self.read_secret(alias)?;
93 Ok(AccountRecord {
94 alias: alias.to_string(),
95 api_key,
96 profile,
97 is_default: state
98 .default_account
99 .as_ref()
100 .map(|default| default == alias)
101 .unwrap_or(false),
102 daily_budget: state.daily_budget(alias),
103 })
104 }
105
106 pub fn list_accounts(&self) -> Result<Vec<AccountSummary>, AccountStoreError> {
108 let state = self.read_state()?;
109 let mut summaries = Vec::with_capacity(state.accounts.len());
110 for (alias, profile) in state.accounts.iter() {
111 summaries.push(AccountSummary {
112 alias: alias.clone(),
113 profile: profile.clone(),
114 is_default: state
115 .default_account
116 .as_ref()
117 .map(|default| default == alias)
118 .unwrap_or(false),
119 daily_budget: state.daily_budget(alias),
120 });
121 }
122 Ok(summaries)
123 }
124
125 pub fn default_alias(&self) -> Result<Option<String>, AccountStoreError> {
127 let state = self.read_state()?;
128 Ok(state.default_account)
129 }
130
131 pub fn set_default(&self, alias: &str) -> Result<(), AccountStoreError> {
133 let mut state = self.read_state()?;
134 if !state.accounts.contains_key(alias) {
135 return Err(AccountStoreError::AccountNotFound(alias.to_string()));
136 }
137 state.default_account = Some(alias.to_string());
138 self.write_state(&state)?;
139 Ok(())
140 }
141
142 pub fn set_daily_budget(
144 &self,
145 alias: &str,
146 budget: Option<NonZeroU32>,
147 ) -> Result<(), AccountStoreError> {
148 let mut state = self.read_state()?;
149 if !state.accounts.contains_key(alias) {
150 return Err(AccountStoreError::AccountNotFound(alias.to_string()));
151 }
152 let entry = state.settings.entry(alias.to_string()).or_default();
153 entry.daily_budget = budget.map(NonZeroU32::get);
154 self.write_state(&state)
155 }
156
157 pub fn remove_account(&self, alias: &str) -> Result<(), AccountStoreError> {
159 let mut state = self.read_state()?;
160 if state.accounts.remove(alias).is_none() {
161 return Err(AccountStoreError::AccountNotFound(alias.to_string()));
162 }
163 state.settings.remove(alias);
164 if state.default_account.as_deref() == Some(alias) {
165 state.default_account = state.accounts.keys().next().cloned();
166 }
167 self.write_state(&state)?;
168 self.delete_secret(alias)?;
169 Ok(())
170 }
171
172 pub fn export_accounts(&self) -> Result<Vec<AccountExport>, AccountStoreError> {
174 Ok(self
175 .list_accounts()?
176 .into_iter()
177 .map(|summary| AccountExport {
178 alias: summary.alias,
179 is_default: summary.is_default,
180 label: summary.profile.label,
181 email: summary.profile.email,
182 description: summary.profile.description,
183 daily_budget: summary.daily_budget.map(|v| v.get()),
184 })
185 .collect())
186 }
187
188 fn read_state(&self) -> Result<AccountsState, AccountStoreError> {
189 let path = self.paths.accounts_file();
190 match fs::File::open(&path) {
191 Ok(mut file) => {
192 let mut contents = String::new();
193 file.read_to_string(&mut contents)
194 .map_err(|source| AccountStoreError::Io {
195 path: path.clone(),
196 source,
197 })?;
198 if contents.trim().is_empty() {
199 return Ok(AccountsState::default());
200 }
201 serde_json::from_str(&contents).map_err(AccountStoreError::Deserialize)
202 }
203 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(AccountsState::default()),
204 Err(source) => Err(AccountStoreError::Io { path, source }),
205 }
206 }
207
208 fn write_state(&self, state: &AccountsState) -> Result<(), AccountStoreError> {
209 let path = self.paths.accounts_file();
210 let contents = serde_json::to_string_pretty(state).map_err(AccountStoreError::Serialize)?;
211 fs::write(&path, contents).map_err(|source| AccountStoreError::Io { path, source })
212 }
213
214 fn write_secret(&self, alias: &str, api_key: &str) -> Result<(), AccountStoreError> {
215 if keyring_enabled() {
216 match store_keyring_secret(alias, api_key) {
217 Ok(()) => {
218 let _ = self.delete_secret_file(alias);
219 return Ok(());
220 }
221 Err(err) => {
222 if cfg!(debug_assertions) {
223 eprintln!("keyring write failed for {alias}: {err:?}");
224 }
225 }
226 }
227 }
228 self.write_secret_file(alias, api_key)
229 }
230
231 fn read_secret(&self, alias: &str) -> Result<String, AccountStoreError> {
232 if keyring_enabled() {
233 match read_keyring_secret(alias) {
234 Ok(secret) => return Ok(secret.trim().to_string()),
235 Err(KeyringError::NoEntry) => {
236 }
238 Err(err) => {
239 if cfg!(debug_assertions) {
240 eprintln!("keyring read failed for {alias}: {err:?}");
241 }
242 }
243 }
244 }
245 self.read_secret_file(alias)
246 }
247
248 fn delete_secret(&self, alias: &str) -> Result<(), AccountStoreError> {
249 if keyring_enabled() {
250 if let Err(err) = delete_keyring_secret(alias) {
251 if cfg!(debug_assertions) {
252 eprintln!("keyring delete failed for {alias}: {err:?}");
253 }
254 }
255 }
256 self.delete_secret_file(alias)
257 }
258
259 fn write_secret_file(&self, alias: &str, api_key: &str) -> Result<(), AccountStoreError> {
260 let path = self.secret_path(alias);
261 let mut file = fs::File::create(&path).map_err(|source| AccountStoreError::Io {
262 path: path.clone(),
263 source,
264 })?;
265 set_restricted_permissions(&path, &mut file)?;
266 file.write_all(api_key.as_bytes())
267 .map_err(|source| AccountStoreError::Io {
268 path: path.clone(),
269 source,
270 })
271 }
272
273 fn read_secret_file(&self, alias: &str) -> Result<String, AccountStoreError> {
274 let path = self.secret_path(alias);
275 let contents = fs::read_to_string(&path).map_err(|source| AccountStoreError::Io {
276 path: path.clone(),
277 source,
278 })?;
279 Ok(contents.trim().to_string())
280 }
281
282 fn delete_secret_file(&self, alias: &str) -> Result<(), AccountStoreError> {
283 let path = self.secret_path(alias);
284 match fs::remove_file(&path) {
285 Ok(()) => Ok(()),
286 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
287 Err(source) => Err(AccountStoreError::Io { path, source }),
288 }
289 }
290
291 fn secret_path(&self, alias: &str) -> PathBuf {
292 self.paths.accounts_dir().join(format!("{alias}.key"))
293 }
294}
295
296fn validate_alias(alias: &str) -> Result<(), AccountStoreError> {
297 if alias.trim().is_empty() {
298 return Err(AccountStoreError::InvalidAlias(
299 "alias cannot be empty".to_string(),
300 ));
301 }
302 if alias.contains('/') || alias.contains('\\') {
303 return Err(AccountStoreError::InvalidAlias(
304 "alias may not contain path separators".to_string(),
305 ));
306 }
307 if alias.contains("..") {
308 return Err(AccountStoreError::InvalidAlias(
309 "alias may not contain parent directory segments".to_string(),
310 ));
311 }
312 Ok(())
313}
314
315#[cfg(unix)]
316fn set_restricted_permissions(path: &Path, file: &mut fs::File) -> Result<(), AccountStoreError> {
317 use std::os::unix::fs::PermissionsExt;
318 let mut perms = file
319 .metadata()
320 .map_err(|source| AccountStoreError::Io {
321 path: path.to_path_buf(),
322 source,
323 })?
324 .permissions();
325 perms.set_mode(0o600);
326 fs::set_permissions(path, perms).map_err(|source| AccountStoreError::Io {
327 path: path.to_path_buf(),
328 source,
329 })
330}
331
332#[cfg(not(unix))]
333fn set_restricted_permissions(_path: &Path, _file: &mut fs::File) -> Result<(), AccountStoreError> {
334 Ok(())
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
338pub struct AccountProfile {
339 #[serde(default)]
340 pub label: Option<String>,
341 #[serde(default)]
342 pub email: Option<String>,
343 #[serde(default)]
344 pub description: Option<String>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct AccountSummary {
350 pub alias: String,
351 pub profile: AccountProfile,
352 pub is_default: bool,
353 pub daily_budget: Option<NonZeroU32>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
358pub struct AccountExport {
359 pub alias: String,
360 pub is_default: bool,
361 pub label: Option<String>,
362 pub email: Option<String>,
363 pub description: Option<String>,
364 pub daily_budget: Option<u32>,
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct AccountRecord {
370 pub alias: String,
371 pub api_key: String,
372 pub profile: AccountProfile,
373 pub is_default: bool,
374 pub daily_budget: Option<NonZeroU32>,
375}
376
377#[derive(Debug, Default, Serialize, Deserialize)]
378struct AccountsState {
379 #[serde(default)]
380 default_account: Option<String>,
381 #[serde(default)]
382 accounts: BTreeMap<String, AccountProfile>,
383 #[serde(default)]
384 settings: BTreeMap<String, AccountSettings>,
385}
386
387#[derive(Debug, Clone, Default, Serialize, Deserialize)]
388struct AccountSettings {
389 #[serde(default)]
390 daily_budget: Option<u32>,
391}
392
393impl AccountsState {
394 fn daily_budget(&self, alias: &str) -> Option<NonZeroU32> {
395 self.settings
396 .get(alias)
397 .and_then(|settings| settings.daily_budget)
398 .and_then(NonZeroU32::new)
399 }
400}
401
402#[derive(thiserror::Error, Debug)]
403pub enum AccountStoreError {
404 #[error("configuration error: {0}")]
405 Config(#[from] ConfigError),
406 #[error("I/O error at {path:?}")]
407 Io {
408 path: PathBuf,
409 #[source]
410 source: std::io::Error,
411 },
412 #[error("failed to parse accounts file: {0}")]
413 Deserialize(serde_json::Error),
414 #[error("failed to serialise accounts file: {0}")]
415 Serialize(serde_json::Error),
416 #[error("account alias already exists: {0}")]
417 DuplicateAlias(String),
418 #[error("account alias is invalid: {0}")]
419 InvalidAlias(String),
420 #[error("account not found: {0}")]
421 AccountNotFound(String),
422 #[error("API key cannot be empty")]
423 EmptyApiKey,
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use tempfile::tempdir;
430
431 fn store_for_test() -> (AccountStore, tempfile::TempDir) {
432 std::env::set_var("PLAUSIBLE_CLI_DISABLE_KEYRING", "1");
433 let tmp = tempdir().expect("tmpdir");
434 let paths = ConfigPaths::from_base_dir(tmp.path());
435 let store = AccountStore::new(paths).expect("store");
436 (store, tmp)
437 }
438
439 #[test]
440 fn add_account_persists_metadata_and_secret() {
441 let (store, _tmp) = store_for_test();
442 store
443 .add_account(
444 "prod",
445 "test-key",
446 AccountProfile {
447 label: Some("Production".into()),
448 email: Some("ops@example.com".into()),
449 description: Some("Primary account".into()),
450 },
451 )
452 .expect("add account");
453
454 let accounts = store.list_accounts().expect("list");
455 assert_eq!(accounts.len(), 1);
456 let summary = &accounts[0];
457 assert_eq!(summary.alias, "prod");
458 assert!(summary.is_default);
459 assert_eq!(summary.profile.label.as_deref(), Some("Production"));
460
461 let record = store.get_account("prod").expect("get account");
462 assert_eq!(record.api_key, "test-key");
463 }
464
465 #[test]
466 fn cannot_add_duplicate_alias() {
467 let (store, _tmp) = store_for_test();
468 store
469 .add_account("dup", "k1", AccountProfile::default())
470 .expect("add first");
471 let err = store
472 .add_account("dup", "k2", AccountProfile::default())
473 .expect_err("duplicate");
474 assert!(matches!(
475 err,
476 AccountStoreError::DuplicateAlias(alias) if alias == "dup"
477 ));
478 }
479
480 #[test]
481 fn set_default_updates_state() {
482 let (store, _tmp) = store_for_test();
483 store
484 .add_account("a", "ka", AccountProfile::default())
485 .expect("add a");
486 store
487 .add_account("b", "kb", AccountProfile::default())
488 .expect("add b");
489
490 store.set_default("b").expect("set default");
491 let accounts = store.list_accounts().expect("list");
492 let defaults: Vec<_> = accounts
493 .into_iter()
494 .filter(|summary| summary.is_default)
495 .collect();
496 assert_eq!(defaults.len(), 1);
497 assert_eq!(defaults[0].alias, "b");
498 }
499
500 #[test]
501 fn remove_account_drops_secret_and_selects_new_default() {
502 let (store, _tmp) = store_for_test();
503 store
504 .add_account("first", "k1", AccountProfile::default())
505 .expect("add first");
506 store
507 .add_account("second", "k2", AccountProfile::default())
508 .expect("add second");
509
510 store.remove_account("first").expect("remove account");
511 let accounts = store.list_accounts().expect("list");
512 assert_eq!(accounts.len(), 1);
513 assert_eq!(accounts[0].alias, "second");
514 assert!(accounts[0].is_default);
515 assert!(store.get_account("first").is_err());
516 }
517
518 #[test]
519 fn set_daily_budget_updates_state() {
520 let (store, _tmp) = store_for_test();
521 store
522 .add_account("acct", "key", AccountProfile::default())
523 .expect("add");
524
525 let budget = NonZeroU32::new(500).unwrap();
526 store
527 .set_daily_budget("acct", Some(budget))
528 .expect("set budget");
529
530 let record = store.get_account("acct").expect("record");
531 assert_eq!(record.daily_budget, Some(budget));
532
533 store.set_daily_budget("acct", None).expect("clear budget");
534 let record = store.get_account("acct").expect("record");
535 assert!(record.daily_budget.is_none());
536 }
537
538 #[test]
539 fn alias_validation_rejects_path_segments() {
540 assert!(validate_alias("valid-alias").is_ok());
541 assert!(validate_alias("nested/alias").is_err());
542 assert!(validate_alias("..").is_err());
543 assert!(validate_alias("").is_err());
544 }
545}