1use std::sync::Arc;
24
25use async_trait::async_trait;
26
27use crate::Result;
28use crate::config::CredentialStore;
29use crate::fs::{config_base_dir, is_safe_path_component};
30
31#[derive(Clone, Copy, Debug)]
36pub struct CredentialKey<'key> {
37 pub app_id: &'key str,
39 pub provider: &'key str,
41 pub env: &'key str,
43}
44
45impl<'key> CredentialKey<'key> {
46 #[must_use]
48 pub fn new(app_id: &'key str, provider: &'key str, env: &'key str) -> Self {
49 Self {
50 app_id,
51 provider,
52 env,
53 }
54 }
55}
56
57#[async_trait]
63pub trait CredentialStorage: Send + Sync + std::fmt::Debug {
64 async fn load(&self, key: &CredentialKey<'_>) -> Option<String>;
69
70 async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()>;
75
76 async fn delete(&self, key: &CredentialKey<'_>);
78
79 async fn list(&self) -> Result<Vec<String>> {
86 Ok(Vec::new())
87 }
88}
89
90#[derive(Clone, Copy, Debug, Default)]
101pub struct FileStorage;
102
103impl FileStorage {
104 #[must_use]
106 pub fn new() -> Self {
107 Self
108 }
109
110 fn path_for(key: &CredentialKey<'_>) -> Option<std::path::PathBuf> {
114 let app = if key.app_id.is_empty() {
115 key.provider
116 } else {
117 key.app_id
118 };
119 if !is_safe_path_component(app)
120 || !is_safe_path_component(key.provider)
121 || !is_safe_path_component(key.env)
122 {
123 tracing::warn!(
124 app,
125 provider = key.provider,
126 env = key.env,
127 "refusing credential path with unsafe component"
128 );
129 return None;
130 }
131 let base = config_base_dir()?;
132 Some(
133 base.join(app)
134 .join("credentials")
135 .join(format!("{}-{}.json", key.provider, key.env)),
136 )
137 }
138}
139
140#[async_trait]
141impl CredentialStorage for FileStorage {
142 async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
143 let path = Self::path_for(key)?;
144 match tokio::fs::read_to_string(&path).await {
145 Ok(s) => Some(s),
146 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
147 Err(e) => {
148 tracing::warn!(path = %path.display(), error = %e, "credential file read failed");
149 None
150 }
151 }
152 }
153
154 async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
155 let path = Self::path_for(key).ok_or_else(|| {
156 crate::error::CliCoreError::message("could not determine credential file path")
157 })?;
158 let value = value.to_owned();
159 tokio::task::spawn_blocking(move || crate::fs::write_string_atomic(&path, &value))
160 .await
161 .map_err(|e| {
162 crate::error::CliCoreError::message(format!(
163 "credential file write task {}: {e}",
164 if e.is_cancelled() {
165 "cancelled"
166 } else {
167 "panicked"
168 }
169 ))
170 })?
171 }
172
173 async fn delete(&self, key: &CredentialKey<'_>) {
174 let Some(path) = Self::path_for(key) else {
175 return;
176 };
177 match tokio::fs::remove_file(&path).await {
178 Ok(()) => {}
179 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
180 Err(e) => {
181 tracing::warn!(path = %path.display(), error = %e, "failed to delete credential file");
182 }
183 }
184 }
185}
186
187#[cfg(feature = "pkce-auth")]
188pub use keychain::{AutoStorage, KeyringStorage};
189
190#[cfg(feature = "pkce-auth")]
191mod keychain {
192 use super::{CredentialKey, CredentialStorage, FileStorage, Result, async_trait};
193
194 const KEYCHAIN_USER: &str = "token";
195
196 fn keychain_service(key: &CredentialKey<'_>) -> String {
199 if key.app_id.is_empty() {
200 format!("{}/{}", key.provider, key.env)
201 } else {
202 format!("{}/{}/{}", key.app_id, key.provider, key.env)
203 }
204 }
205
206 #[derive(Clone, Copy, Debug, Default)]
218 pub struct KeyringStorage;
219
220 impl KeyringStorage {
221 #[must_use]
223 pub fn new() -> Self {
224 Self
225 }
226
227 pub(super) async fn read_three_state(
233 &self,
234 key: &CredentialKey<'_>,
235 ) -> Option<Option<String>> {
236 let service = keychain_service(key);
237 match tokio::task::spawn_blocking({
238 let service = service.clone();
239 move || keychain_read_blocking(&service, KEYCHAIN_USER)
240 })
241 .await
242 {
243 Ok(result) => result,
244 Err(e) => {
245 let reason = if e.is_cancelled() {
246 "cancelled"
247 } else {
248 "panicked"
249 };
250 tracing::warn!(service, error = %e, reason, "keychain read task failed");
251 None
252 }
253 }
254 }
255
256 pub(super) async fn write_raw(&self, key: &CredentialKey<'_>, value: &str) -> bool {
258 let service = keychain_service(key);
259 let value = value.to_owned();
260 match tokio::task::spawn_blocking({
261 let service = service.clone();
262 move || keychain_write_blocking(&service, KEYCHAIN_USER, &value)
263 })
264 .await
265 {
266 Ok(saved) => saved,
267 Err(e) => {
268 let reason = if e.is_cancelled() {
269 "cancelled"
270 } else {
271 "panicked"
272 };
273 tracing::warn!(service, error = %e, reason, "keychain write task failed");
274 false
275 }
276 }
277 }
278
279 pub(super) async fn delete_entry(&self, key: &CredentialKey<'_>) {
281 let service = keychain_service(key);
282 let service_for_warn = service.clone();
283 if let Err(e) =
284 tokio::task::spawn_blocking(move || match keyring::Entry::new(&service, KEYCHAIN_USER) {
285 Err(e) => {
286 tracing::warn!(service, error = %e, "keychain entry creation failed on delete");
287 }
288 Ok(entry) => match entry.delete_credential() {
289 Ok(()) | Err(keyring::Error::NoEntry) => {}
290 Err(e) => {
291 tracing::warn!(service, error = %e, "keychain delete failed");
292 }
293 },
294 })
295 .await
296 {
297 let reason = if e.is_cancelled() {
298 "cancelled"
299 } else {
300 "panicked"
301 };
302 tracing::warn!(service = service_for_warn, error = %e, reason, "keychain delete task failed");
303 }
304 }
305 }
306
307 #[async_trait]
308 impl CredentialStorage for KeyringStorage {
309 async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
310 self.read_three_state(key).await.flatten()
313 }
314
315 async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
316 if self.write_raw(key, value).await {
317 Ok(())
318 } else {
319 Err(crate::error::CliCoreError::message(
320 "failed to save token to keychain — check logs for the underlying error, \
321 ensure your system keychain (e.g. gnome-keyring, macOS Keychain) is running \
322 and unlocked, or select file storage (credential store \"file\" or \"auto\")",
323 ))
324 }
325 }
326
327 async fn delete(&self, key: &CredentialKey<'_>) {
328 self.delete_entry(key).await;
329 }
330 }
331
332 #[derive(Clone, Copy, Debug, Default)]
343 pub struct AutoStorage {
344 file: FileStorage,
345 keyring: KeyringStorage,
346 }
347
348 impl AutoStorage {
349 #[must_use]
351 pub fn new() -> Self {
352 Self {
353 file: FileStorage::new(),
354 keyring: KeyringStorage::new(),
355 }
356 }
357 }
358
359 #[async_trait]
360 impl CredentialStorage for AutoStorage {
361 async fn load(&self, key: &CredentialKey<'_>) -> Option<String> {
362 match self.keyring.read_three_state(key).await {
363 Some(Some(json)) => Some(json),
365 Some(None) => None,
367 None => self.file.load(key).await,
369 }
370 }
371
372 async fn save(&self, key: &CredentialKey<'_>, value: &str) -> Result<()> {
373 if self.keyring.write_raw(key, value).await {
374 self.file.delete(key).await;
376 return Ok(());
377 }
378 self.file.save(key, value).await
379 }
380
381 async fn delete(&self, key: &CredentialKey<'_>) {
382 self.keyring.delete(key).await;
383 self.file.delete(key).await;
384 }
385 }
386
387 fn keychain_read_blocking(service: &str, user: &str) -> Option<Option<String>> {
390 match keyring::Entry::new(service, user) {
391 Err(e) => {
392 tracing::warn!(service, error = %e, "keychain entry creation failed");
393 None
394 }
395 Ok(entry) => match entry.get_password() {
396 Err(keyring::Error::NoEntry) => {
397 tracing::debug!(service, "no stored token in keychain");
398 Some(None)
399 }
400 Err(e) => {
401 tracing::warn!(service, error = %e, "keychain read failed");
402 None
403 }
404 Ok(json) => Some(Some(json)),
405 },
406 }
407 }
408
409 fn keychain_write_blocking(service: &str, user: &str, json: &str) -> bool {
412 match keyring::Entry::new(service, user) {
413 Err(e) => {
414 tracing::warn!(service, error = %e, "keychain entry creation failed");
415 false
416 }
417 Ok(entry) => match entry.set_password(json) {
418 Err(e) => {
419 tracing::warn!(service, error = %e, "keychain write failed");
420 false
421 }
422 Ok(()) => {
423 tracing::debug!(service, "token saved to keychain");
424 true
425 }
426 },
427 }
428 }
429}
430
431#[must_use]
438pub fn storage_for(mode: CredentialStore) -> Arc<dyn CredentialStorage> {
439 match mode {
440 CredentialStore::File => Arc::new(FileStorage::new()),
441 #[cfg(feature = "pkce-auth")]
442 CredentialStore::Keyring => Arc::new(KeyringStorage::new()),
443 #[cfg(feature = "pkce-auth")]
444 CredentialStore::Auto => Arc::new(AutoStorage::new()),
445 #[cfg(not(feature = "pkce-auth"))]
446 mode => {
447 tracing::warn!(
448 %mode,
449 "keyring backends unavailable (pkce-auth feature disabled); using file storage"
450 );
451 Arc::new(FileStorage::new())
452 }
453 }
454}
455
456#[must_use]
470pub fn default_storage(app_id: &str) -> Arc<dyn CredentialStorage> {
471 let mode = crate::config::resolve_credential_store(app_id, |k| std::env::var(k).ok());
472 storage_for(mode)
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::config::test_env::{EnvVarGuard, lock, with_xdg_config_home};
479
480 #[test]
481 fn file_path_uses_app_id_and_provider() {
482 let dir = std::env::temp_dir().join("cli-engine-storage-test-xdg");
483 with_xdg_config_home(&dir, || {
484 let key = CredentialKey::new("myapp", "prov", "prod");
485 assert_eq!(
486 FileStorage::path_for(&key),
487 Some(dir.join("myapp").join("credentials").join("prov-prod.json"))
488 );
489 let key2 = CredentialKey::new("", "prov", "prod");
491 assert_eq!(
492 FileStorage::path_for(&key2),
493 Some(dir.join("prov").join("credentials").join("prov-prod.json"))
494 );
495 });
496 }
497
498 #[test]
499 fn file_path_rejects_unsafe_components() {
500 for env in ["../../etc/passwd", "dev/subdir", "dev\\subdir", ".."] {
501 let key = CredentialKey::new("app", "prov", env);
502 assert_eq!(
503 FileStorage::path_for(&key),
504 None,
505 "{env:?} should be rejected"
506 );
507 }
508 }
509
510 #[test]
511 fn file_path_rejects_relative_base_dir() {
512 with_xdg_config_home(std::path::Path::new("."), || {
513 let key = CredentialKey::new("app", "prov", "dev");
514 assert_eq!(FileStorage::path_for(&key), None);
515 });
516 }
517
518 #[tokio::test]
519 #[allow(clippy::await_holding_lock)]
521 async fn file_storage_round_trip() {
522 let dir = tempfile::tempdir().expect("tempdir");
523 let _lock = lock();
526 let _env = EnvVarGuard::set("XDG_CONFIG_HOME", Some(dir.path()));
527
528 let store = FileStorage::new();
529 let key = CredentialKey::new("app", "prov", "dev");
530 assert_eq!(store.load(&key).await, None);
531 store.save(&key, "{\"token\":\"abc\"}").await.expect("save");
532 assert_eq!(
533 store.load(&key).await.as_deref(),
534 Some("{\"token\":\"abc\"}")
535 );
536 store.delete(&key).await;
537 assert_eq!(store.load(&key).await, None);
538 }
539
540 #[test]
541 fn storage_for_file_is_always_available() {
542 let store = storage_for(CredentialStore::File);
544 assert!(format!("{store:?}").contains("FileStorage"));
545 }
546}