Skip to main content

vtcode_auth/
credentials.rs

1//! Generic credential storage with OS keyring and file-based backends.
2//!
3//! This module provides a unified interface for storing sensitive credentials
4//! securely using the OS keyring (macOS Keychain, Windows Credential Manager,
5//! Linux Secret Service) with fallback to AES-256-GCM encrypted files.
6//!
7//! ## Usage
8//!
9//! ```rust
10//! use vtcode_auth::{AuthCredentialsStoreMode, CredentialStorage};
11//!
12//! # fn example() -> anyhow::Result<()> {
13//! // Store a credential using the default mode (keyring)
14//! let storage = CredentialStorage::new("my_app", "api_key");
15//! storage.store("secret_api_key")?;
16//!
17//! // Retrieve the credential
18//! if let Some(value) = storage.load()? {
19//!     println!("Found credential: {}", value);
20//! }
21//!
22//! // Delete the credential
23//! storage.clear()?;
24//! # Ok(())
25//! # }
26//! ```
27
28use anyhow::{Context, Result, anyhow};
29use base64::Engine;
30use base64::engine::general_purpose::STANDARD;
31use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
32use ring::rand::{SecureRandom, SystemRandom};
33use serde::{Deserialize, Serialize};
34use std::collections::BTreeMap;
35use std::fs;
36
37use crate::storage_paths::auth_storage_dir;
38use crate::storage_paths::legacy_auth_storage_path;
39use crate::storage_paths::write_private_file;
40
41const ENCRYPTED_CREDENTIAL_VERSION: u8 = 1;
42
43#[derive(Debug, Serialize, Deserialize)]
44struct EncryptedCredential {
45    nonce: String,
46    ciphertext: String,
47    version: u8,
48    /// Per-file random salt for HKDF-style key derivation.
49    /// Older files (version 1) will lack this field; serde defaults to None.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    salt: Option<String>,
52}
53
54#[derive(Debug, Deserialize)]
55struct LegacyAuthFile {
56    mode: String,
57    provider: String,
58    api_key: String,
59}
60
61/// Preferred storage backend for credentials.
62///
63/// - `Keyring`: Use OS-specific secure storage (macOS Keychain, Windows Credential Manager,
64///   Linux Secret Service). This is the default as it's the most secure option.
65/// - `File`: Use AES-256-GCM encrypted file (requires the `file-storage` feature or
66///   custom implementation)
67/// - `Auto`: Try keyring first, fall back to file if unavailable
68#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70#[serde(rename_all = "lowercase")]
71pub enum AuthCredentialsStoreMode {
72    /// Use OS-specific keyring service.
73    /// This is the most secure option as credentials are managed by the OS
74    /// and are not accessible to other users or applications.
75    Keyring,
76    /// Persist credentials in an encrypted file.
77    /// The file is encrypted with AES-256-GCM using a machine-derived key.
78    File,
79    /// Use keyring when available; otherwise, fall back to file.
80    Auto,
81}
82
83impl Default for AuthCredentialsStoreMode {
84    /// Default to keyring on all platforms for maximum security.
85    /// Falls back to file-based storage if keyring is unavailable.
86    fn default() -> Self {
87        Self::Keyring
88    }
89}
90
91impl AuthCredentialsStoreMode {
92    /// Get the effective storage mode, resolving Auto to the best available option.
93    pub fn effective_mode(self) -> Self {
94        match self {
95            Self::Auto => {
96                // Check if keyring is functional by attempting to create an entry
97                if is_keyring_functional() {
98                    Self::Keyring
99                } else {
100                    tracing::debug!("Keyring not available, falling back to file storage");
101                    Self::File
102                }
103            }
104            mode => mode,
105        }
106    }
107}
108
109/// Check if the OS keyring is functional by attempting a test operation.
110///
111/// This creates a test entry, verifies it can be written and read, then deletes it.
112/// This is more reliable than just checking if Entry creation succeeds.
113pub(crate) fn is_keyring_functional() -> bool {
114    // Create a test entry with a unique name to avoid conflicts
115    let test_user = format!("test_{}", std::process::id());
116    let entry = match keyring_entry("vtcode", &test_user) {
117        Ok(e) => e,
118        Err(_) => return false,
119    };
120
121    // Try to write a test value
122    if entry.set_password("test").is_err() {
123        return false;
124    }
125
126    // Try to read it back
127    let functional = entry.get_password().is_ok();
128
129    // Clean up - ignore errors during cleanup (called unconditionally so a
130    // stale entry does not persist when get_password fails after set succeeds).
131    let _ = entry.delete_credential();
132
133    functional
134}
135
136fn ensure_native_keyring_store() -> keyring_core::Result<()> {
137    if keyring_core::get_default_store().is_some() {
138        return Ok(());
139    }
140
141    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
142    let store = dbus_secret_service_keyring_store::Store::new_with_configuration(
143        &std::collections::HashMap::new(),
144    )?;
145
146    #[cfg(target_os = "macos")]
147    let store = apple_native_keyring_store::keychain::Store::new_with_configuration(
148        &std::collections::HashMap::new(),
149    )?;
150
151    #[cfg(target_os = "windows")]
152    let store = windows_native_keyring_store::Store::new_with_configuration(
153        &std::collections::HashMap::new(),
154    )?;
155
156    #[cfg(not(any(
157        target_os = "linux",
158        target_os = "freebsd",
159        target_os = "macos",
160        target_os = "windows"
161    )))]
162    {
163        return Err(keyring_core::Error::NotSupportedByStore(
164            "VT Code does not have a native keyring store configured for this platform".to_string(),
165        ));
166    }
167
168    keyring_core::set_default_store(store);
169    Ok(())
170}
171
172pub(crate) fn keyring_entry(
173    service: &str,
174    user: &str,
175) -> keyring_core::Result<keyring_core::Entry> {
176    if keyring_core::get_default_store().is_none() {
177        ensure_native_keyring_store()?;
178    }
179
180    keyring_core::Entry::new(service, user)
181}
182
183/// Generic credential storage interface.
184///
185/// Provides methods to store, load, and clear credentials using either
186/// the OS keyring or file-based storage.
187pub struct CredentialStorage {
188    service: String,
189    user: String,
190}
191
192impl CredentialStorage {
193    /// Create a new credential storage handle.
194    ///
195    /// # Arguments
196    /// * `service` - The service name (e.g., "vtcode", "openrouter", "github")
197    /// * `user` - The user/account identifier (e.g., "api_key", "oauth_token")
198    pub fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
199        Self {
200            service: service.into(),
201            user: user.into(),
202        }
203    }
204
205    /// Store a credential using the specified mode.
206    ///
207    /// # Arguments
208    /// * `value` - The credential value to store
209    /// * `mode` - The storage mode to use
210    pub fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
211        match mode.effective_mode() {
212            AuthCredentialsStoreMode::Keyring => match self.store_keyring(value) {
213                Ok(()) => {
214                    let _ = self.clear_file();
215                    Ok(())
216                }
217                Err(err) => {
218                    tracing::warn!(
219                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
220                        self.service,
221                        self.user,
222                        err
223                    );
224                    self.store_file(value)
225                        .context("failed to store credential in encrypted file")
226                }
227            },
228            AuthCredentialsStoreMode::File => self.store_file(value),
229            _ => unreachable!(),
230        }
231    }
232
233    /// Store a credential using the default mode (keyring).
234    pub fn store(&self, value: &str) -> Result<()> {
235        self.store_keyring(value)
236    }
237
238    /// Store credential in OS keyring.
239    fn store_keyring(&self, value: &str) -> Result<()> {
240        let entry =
241            keyring_entry(&self.service, &self.user).context("Failed to access OS keyring")?;
242
243        entry
244            .set_password(value)
245            .context("Failed to store credential in OS keyring")?;
246
247        tracing::debug!(
248            "Credential stored in OS keyring for {}/{}",
249            self.service,
250            self.user
251        );
252        Ok(())
253    }
254
255    /// Load a credential using the specified mode.
256    ///
257    /// Returns `None` if no credential exists.
258    pub fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
259        match mode.effective_mode() {
260            AuthCredentialsStoreMode::Keyring => match self.load_keyring() {
261                Ok(Some(value)) => Ok(Some(value)),
262                Ok(None) => self.load_file(),
263                Err(err) => {
264                    tracing::warn!(
265                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
266                        self.service,
267                        self.user,
268                        err
269                    );
270                    self.load_file()
271                }
272            },
273            AuthCredentialsStoreMode::File => self.load_file(),
274            _ => unreachable!(),
275        }
276    }
277
278    /// Load a credential using the default mode (keyring).
279    ///
280    /// Returns `None` if no credential exists.
281    pub fn load(&self) -> Result<Option<String>> {
282        self.load_keyring()
283    }
284
285    /// Load credential from OS keyring.
286    fn load_keyring(&self) -> Result<Option<String>> {
287        let entry = match keyring_entry(&self.service, &self.user) {
288            Ok(e) => e,
289            Err(_) => return Ok(None),
290        };
291
292        match entry.get_password() {
293            Ok(value) => Ok(Some(value)),
294            Err(keyring_core::Error::NoEntry) => Ok(None),
295            Err(e) => Err(anyhow!("Failed to read from keyring: {}", e)),
296        }
297    }
298
299    /// Clear (delete) a credential using the specified mode.
300    pub fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
301        match mode.effective_mode() {
302            AuthCredentialsStoreMode::Keyring => {
303                let mut errors = Vec::new();
304
305                if let Err(err) = self.clear_keyring() {
306                    errors.push(err.to_string());
307                }
308                if let Err(err) = self.clear_file() {
309                    errors.push(err.to_string());
310                }
311
312                if errors.is_empty() {
313                    Ok(())
314                } else {
315                    Err(anyhow!(
316                        "Failed to clear credential from secure storage: {}",
317                        errors.join("; ")
318                    ))
319                }
320            }
321            AuthCredentialsStoreMode::File => self.clear_file(),
322            _ => unreachable!(),
323        }
324    }
325
326    /// Clear (delete) a credential using the default mode.
327    pub fn clear(&self) -> Result<()> {
328        self.clear_keyring()
329    }
330
331    /// Clear credential from OS keyring.
332    fn clear_keyring(&self) -> Result<()> {
333        let entry = match keyring_entry(&self.service, &self.user) {
334            Ok(e) => e,
335            Err(_) => return Ok(()),
336        };
337
338        match entry.delete_credential() {
339            Ok(_) => {
340                tracing::debug!(
341                    "Credential cleared from keyring for {}/{}",
342                    self.service,
343                    self.user
344                );
345            }
346            Err(keyring_core::Error::NoEntry) => {}
347            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {}", e)),
348        }
349
350        Ok(())
351    }
352
353    fn store_file(&self, value: &str) -> Result<()> {
354        let path = self.file_path()?;
355        let encrypted = encrypt_credential(value)?;
356        let payload = serde_json::to_vec_pretty(&encrypted)
357            .context("failed to serialize encrypted credential")?;
358        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
359
360        Ok(())
361    }
362
363    fn load_file(&self) -> Result<Option<String>> {
364        let path = self.file_path()?;
365        let data = match fs::read(&path) {
366            Ok(data) => data,
367            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
368            Err(err) => return Err(anyhow!("failed to read encrypted credential file: {err}")),
369        };
370
371        let encrypted: EncryptedCredential =
372            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
373        decrypt_credential(&encrypted).map(Some)
374    }
375
376    fn clear_file(&self) -> Result<()> {
377        let path = self.file_path()?;
378        match fs::remove_file(path) {
379            Ok(()) => Ok(()),
380            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
381            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
382        }
383    }
384
385    fn file_path(&self) -> Result<std::path::PathBuf> {
386        use sha2::Digest as _;
387
388        let mut hasher = sha2::Sha256::new();
389        hasher.update(self.service.as_bytes());
390        hasher.update([0]);
391        hasher.update(self.user.as_bytes());
392        let digest = hasher.finalize();
393        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
394
395        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
396    }
397}
398
399/// Custom API Key storage for provider-specific keys.
400///
401/// Provides secure storage and retrieval of API keys for custom providers
402/// using the OS keyring or encrypted file storage.
403pub struct CustomApiKeyStorage {
404    provider: String,
405    storage: CredentialStorage,
406}
407
408impl CustomApiKeyStorage {
409    /// Create a new custom API key storage for a specific provider.
410    ///
411    /// # Arguments
412    /// * `provider` - The provider identifier (e.g., "openrouter", "anthropic", "custom_provider")
413    pub fn new(provider: &str) -> Self {
414        let normalized_provider = provider.to_lowercase();
415        Self {
416            provider: normalized_provider.clone(),
417            storage: CredentialStorage::new("vtcode", format!("api_key_{normalized_provider}")),
418        }
419    }
420
421    /// Store an API key securely.
422    ///
423    /// # Arguments
424    /// * `api_key` - The API key value to store
425    /// * `mode` - The storage mode to use (defaults to keyring)
426    pub fn store(&self, api_key: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
427        self.storage.store_with_mode(api_key, mode)?;
428        clear_legacy_auth_file_if_matches(&self.provider)?;
429        Ok(())
430    }
431
432    /// Retrieve a stored API key.
433    ///
434    /// Returns `None` if no key is stored.
435    pub fn load(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
436        if let Some(key) = self.storage.load_with_mode(mode)? {
437            return Ok(Some(key));
438        }
439
440        self.load_legacy_auth_json(mode)
441    }
442
443    /// Clear (delete) a stored API key.
444    pub fn clear(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
445        self.storage.clear_with_mode(mode)?;
446        clear_legacy_auth_file_if_matches(&self.provider)?;
447        Ok(())
448    }
449
450    fn load_legacy_auth_json(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
451        let Some(legacy) = load_legacy_auth_file_for_provider(&self.provider)? else {
452            return Ok(None);
453        };
454
455        if let Err(err) = self.storage.store_with_mode(&legacy.api_key, mode) {
456            tracing::warn!(
457                "Failed to migrate legacy plaintext auth.json entry for provider '{}' into secure storage: {}",
458                self.provider,
459                err
460            );
461            return Ok(Some(legacy.api_key));
462        }
463
464        clear_legacy_auth_file_if_matches(&self.provider)?;
465        tracing::warn!(
466            "Migrated legacy plaintext auth.json entry for provider '{}' into secure storage",
467            self.provider
468        );
469        Ok(Some(legacy.api_key))
470    }
471}
472
473fn encrypt_credential(value: &str) -> Result<EncryptedCredential> {
474    // Generate a per-file random salt to diversify the encryption key.
475    let rng = SystemRandom::new();
476    let mut salt_bytes = [0_u8; 16];
477    rng.fill(&mut salt_bytes)
478        .map_err(|_| anyhow!("failed to generate credential salt"))?;
479    let salt = STANDARD.encode(salt_bytes);
480
481    let key = derive_file_encryption_key(Some(&salt))?;
482    let mut nonce_bytes = [0_u8; NONCE_LEN];
483    rng.fill(&mut nonce_bytes)
484        .map_err(|_| anyhow!("failed to generate credential nonce"))?;
485
486    let mut ciphertext = value.as_bytes().to_vec();
487    key.seal_in_place_append_tag(
488        Nonce::assume_unique_for_key(nonce_bytes),
489        Aad::empty(),
490        &mut ciphertext,
491    )
492    .map_err(|_| anyhow!("failed to encrypt credential"))?;
493
494    Ok(EncryptedCredential {
495        nonce: STANDARD.encode(nonce_bytes),
496        ciphertext: STANDARD.encode(ciphertext),
497        version: ENCRYPTED_CREDENTIAL_VERSION,
498        salt: Some(salt),
499    })
500}
501
502fn decrypt_credential(encrypted: &EncryptedCredential) -> Result<String> {
503    if encrypted.version != ENCRYPTED_CREDENTIAL_VERSION {
504        return Err(anyhow!("unsupported encrypted credential format"));
505    }
506
507    let nonce_bytes = STANDARD
508        .decode(&encrypted.nonce)
509        .context("failed to decode credential nonce")?;
510    let nonce_array: [u8; NONCE_LEN] = nonce_bytes
511        .try_into()
512        .map_err(|_| anyhow!("invalid credential nonce length"))?;
513    let mut ciphertext = STANDARD
514        .decode(&encrypted.ciphertext)
515        .context("failed to decode credential ciphertext")?;
516
517    // Backward-compatible: older files won't have a salt (version 1 format).
518    let key = derive_file_encryption_key(encrypted.salt.as_deref())?;
519    let plaintext = key
520        .open_in_place(
521            Nonce::assume_unique_for_key(nonce_array),
522            Aad::empty(),
523            &mut ciphertext,
524        )
525        .map_err(|_| anyhow!("failed to decrypt credential"))?;
526
527    String::from_utf8(plaintext.to_vec()).context("failed to parse decrypted credential")
528}
529
530fn derive_file_encryption_key(salt: Option<&str>) -> Result<LessSafeKey> {
531    use ring::digest::SHA256;
532    use ring::digest::digest;
533
534    let mut key_material = Vec::new();
535    if let Ok(hostname) = hostname::get() {
536        key_material.extend_from_slice(hostname.as_encoded_bytes());
537    }
538
539    #[cfg(unix)]
540    {
541        key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
542    }
543    #[cfg(not(unix))]
544    {
545        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
546            key_material.extend_from_slice(user.as_bytes());
547        }
548    }
549
550    key_material.extend_from_slice(b"vtcode-credentials-v1");
551
552    // Per-file random salt diversifies keys across credentials so that a
553    // compromise of one file does not expose others encrypted under the same
554    // machine+user key material.
555    if let Some(salt) = salt {
556        key_material.extend_from_slice(salt.as_bytes());
557    }
558
559    let hash = digest(&SHA256, &key_material);
560    let key_bytes: &[u8; 32] = hash.as_ref()[..32]
561        .try_into()
562        .context("credential encryption key was too short")?;
563    let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
564        .map_err(|_| anyhow!("invalid credential encryption key"))?;
565    Ok(LessSafeKey::new(unbound))
566}
567
568fn load_legacy_auth_file_for_provider(provider: &str) -> Result<Option<LegacyAuthFile>> {
569    let path = legacy_auth_storage_path()?;
570    let data = match fs::read(&path) {
571        Ok(data) => data,
572        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
573        Err(err) => return Err(anyhow!("failed to read legacy auth file: {err}")),
574    };
575
576    let legacy: LegacyAuthFile =
577        serde_json::from_slice(&data).context("failed to parse legacy auth file")?;
578    let matches_provider = legacy.provider.eq_ignore_ascii_case(provider);
579    let stores_api_key = legacy.mode.eq_ignore_ascii_case("api_key");
580    let has_key = !legacy.api_key.trim().is_empty();
581
582    if matches_provider && stores_api_key && has_key {
583        Ok(Some(legacy))
584    } else {
585        Ok(None)
586    }
587}
588
589fn clear_legacy_auth_file_if_matches(provider: &str) -> Result<()> {
590    let path = legacy_auth_storage_path()?;
591    let Some(_legacy) = load_legacy_auth_file_for_provider(provider)? else {
592        return Ok(());
593    };
594
595    match fs::remove_file(path) {
596        Ok(()) => Ok(()),
597        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
598        Err(err) => Err(anyhow!("failed to delete legacy auth file: {err}")),
599    }
600}
601
602/// Migrate plain-text API keys from config to secure storage.
603///
604/// This function reads API keys from the provided BTreeMap and stores them
605/// securely using the specified storage mode. After migration, the keys
606/// should be removed from the config file.
607///
608/// # Arguments
609/// * `custom_api_keys` - Map of provider names to API keys (from config)
610/// * `mode` - The storage mode to use
611///
612/// # Returns
613/// A map of providers that were successfully migrated (for tracking purposes)
614pub fn migrate_custom_api_keys_to_keyring(
615    custom_api_keys: &BTreeMap<String, String>,
616    mode: AuthCredentialsStoreMode,
617) -> Result<BTreeMap<String, bool>> {
618    let mut migration_results = BTreeMap::new();
619
620    for (provider, api_key) in custom_api_keys {
621        let storage = CustomApiKeyStorage::new(provider);
622        match storage.store(api_key, mode) {
623            Ok(()) => {
624                tracing::info!(
625                    "Migrated API key for provider '{}' to secure storage",
626                    provider
627                );
628                migration_results.insert(provider.clone(), true);
629            }
630            Err(e) => {
631                tracing::warn!(
632                    "Failed to migrate API key for provider '{}': {}",
633                    provider,
634                    e
635                );
636                migration_results.insert(provider.clone(), false);
637            }
638        }
639    }
640
641    Ok(migration_results)
642}
643
644/// Load all custom API keys from secure storage.
645///
646/// This function retrieves API keys for all providers that have keys stored.
647///
648/// # Arguments
649/// * `providers` - List of provider names to check for stored keys
650/// * `mode` - The storage mode to use
651///
652/// # Returns
653/// A BTreeMap of provider names to their API keys (only includes providers with stored keys)
654pub fn load_custom_api_keys(
655    providers: &[String],
656    mode: AuthCredentialsStoreMode,
657) -> Result<BTreeMap<String, String>> {
658    let mut api_keys = BTreeMap::new();
659
660    for provider in providers {
661        let storage = CustomApiKeyStorage::new(provider);
662        if let Some(key) = storage.load(mode)? {
663            api_keys.insert(provider.clone(), key);
664        }
665    }
666
667    Ok(api_keys)
668}
669
670/// Clear all custom API keys from secure storage.
671///
672/// # Arguments
673/// * `providers` - List of provider names to clear
674/// * `mode` - The storage mode to use
675pub fn clear_custom_api_keys(providers: &[String], mode: AuthCredentialsStoreMode) -> Result<()> {
676    for provider in providers {
677        let storage = CustomApiKeyStorage::new(provider);
678        if let Err(e) = storage.clear(mode) {
679            tracing::warn!("Failed to clear API key for provider '{}': {}", provider, e);
680        }
681    }
682    Ok(())
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use assert_fs::TempDir;
689    use serial_test::serial;
690
691    struct TestAuthDirGuard {
692        temp_dir: Option<TempDir>,
693        previous: Option<std::path::PathBuf>,
694    }
695
696    impl TestAuthDirGuard {
697        fn new() -> Self {
698            let temp_dir = TempDir::new().expect("create temp auth dir");
699            let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
700                .expect("read auth dir override");
701            crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
702                temp_dir.path().to_path_buf(),
703            ))
704            .expect("set auth dir override");
705
706            Self {
707                temp_dir: Some(temp_dir),
708                previous,
709            }
710        }
711    }
712
713    impl Drop for TestAuthDirGuard {
714        fn drop(&mut self) {
715            crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
716                .expect("restore auth dir override");
717            if let Some(temp_dir) = self.temp_dir.take() {
718                temp_dir.close().expect("remove temp auth dir");
719            }
720        }
721    }
722
723    #[test]
724    fn test_storage_mode_default_is_keyring() {
725        assert_eq!(
726            AuthCredentialsStoreMode::default(),
727            AuthCredentialsStoreMode::Keyring
728        );
729    }
730
731    #[test]
732    fn test_storage_mode_effective_mode() {
733        assert_eq!(
734            AuthCredentialsStoreMode::Keyring.effective_mode(),
735            AuthCredentialsStoreMode::Keyring
736        );
737        assert_eq!(
738            AuthCredentialsStoreMode::File.effective_mode(),
739            AuthCredentialsStoreMode::File
740        );
741
742        // Auto should resolve to either Keyring or File
743        let auto_mode = AuthCredentialsStoreMode::Auto.effective_mode();
744        assert!(
745            auto_mode == AuthCredentialsStoreMode::Keyring
746                || auto_mode == AuthCredentialsStoreMode::File
747        );
748    }
749
750    #[test]
751    fn test_storage_mode_serialization() {
752        let keyring_json = serde_json::to_string(&AuthCredentialsStoreMode::Keyring).unwrap();
753        assert_eq!(keyring_json, "\"keyring\"");
754
755        let file_json = serde_json::to_string(&AuthCredentialsStoreMode::File).unwrap();
756        assert_eq!(file_json, "\"file\"");
757
758        let auto_json = serde_json::to_string(&AuthCredentialsStoreMode::Auto).unwrap();
759        assert_eq!(auto_json, "\"auto\"");
760
761        // Test deserialization
762        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"keyring\"").unwrap();
763        assert_eq!(parsed, AuthCredentialsStoreMode::Keyring);
764
765        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"file\"").unwrap();
766        assert_eq!(parsed, AuthCredentialsStoreMode::File);
767
768        let parsed: AuthCredentialsStoreMode = serde_json::from_str("\"auto\"").unwrap();
769        assert_eq!(parsed, AuthCredentialsStoreMode::Auto);
770    }
771
772    #[test]
773    fn test_credential_storage_new() {
774        let storage = CredentialStorage::new("vtcode", "test_key");
775        assert_eq!(storage.service, "vtcode");
776        assert_eq!(storage.user, "test_key");
777    }
778
779    #[test]
780    fn test_is_keyring_functional_check() {
781        // This test just verifies the function doesn't panic
782        // The actual result depends on the OS environment
783        let _functional = is_keyring_functional();
784    }
785
786    #[test]
787    #[serial]
788    fn credential_storage_file_mode_round_trips_without_plaintext() {
789        let _guard = TestAuthDirGuard::new();
790        let storage = CredentialStorage::new("vtcode", "test_key");
791
792        storage
793            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
794            .expect("store encrypted credential");
795
796        let loaded = storage
797            .load_with_mode(AuthCredentialsStoreMode::File)
798            .expect("load encrypted credential");
799        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
800
801        let stored = fs::read_to_string(storage.file_path().expect("credential path"))
802            .expect("read encrypted credential file");
803        assert!(!stored.contains("secret_api_key"));
804    }
805
806    #[test]
807    #[serial]
808    fn keyring_mode_load_falls_back_to_encrypted_file() {
809        let _guard = TestAuthDirGuard::new();
810        let storage = CredentialStorage::new("vtcode", "test_key");
811
812        storage
813            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
814            .expect("store encrypted credential");
815
816        let loaded = storage
817            .load_with_mode(AuthCredentialsStoreMode::Keyring)
818            .expect("load credential");
819        assert_eq!(loaded.as_deref(), Some("secret_api_key"));
820    }
821
822    #[test]
823    #[serial]
824    #[cfg(unix)]
825    fn credential_storage_file_mode_uses_private_permissions() {
826        use std::os::unix::fs::PermissionsExt;
827
828        let _guard = TestAuthDirGuard::new();
829        let storage = CredentialStorage::new("vtcode", "test_key");
830
831        storage
832            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
833            .expect("store encrypted credential");
834
835        let metadata = fs::metadata(storage.file_path().expect("credential path"))
836            .expect("read credential metadata");
837        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
838    }
839
840    #[test]
841    #[serial]
842    #[cfg(unix)]
843    fn credential_storage_file_mode_restricts_existing_file_permissions() {
844        use std::os::unix::fs::PermissionsExt;
845
846        let _guard = TestAuthDirGuard::new();
847        let storage = CredentialStorage::new("vtcode", "test_key");
848
849        storage
850            .store_with_mode("secret_api_key", AuthCredentialsStoreMode::File)
851            .expect("store initial credential");
852
853        let path = storage.file_path().expect("credential path");
854        fs::set_permissions(&path, fs::Permissions::from_mode(0o644))
855            .expect("broaden existing credential permissions");
856
857        storage
858            .store_with_mode("secret_api_key_updated", AuthCredentialsStoreMode::File)
859            .expect("rewrite credential");
860
861        let metadata = fs::metadata(path).expect("read credential metadata");
862        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
863    }
864
865    #[test]
866    #[serial]
867    fn custom_api_key_load_migrates_legacy_auth_json() {
868        let _guard = TestAuthDirGuard::new();
869        let legacy_path = legacy_auth_storage_path().expect("legacy auth path");
870        fs::write(
871            &legacy_path,
872            r#"{
873  "version": 1,
874  "mode": "api_key",
875  "provider": "openai",
876  "api_key": "legacy-secret",
877  "authenticated_at": 1768406185
878}"#,
879        )
880        .expect("write legacy auth file");
881
882        let storage = CustomApiKeyStorage::new("openai");
883        let loaded = storage
884            .load(AuthCredentialsStoreMode::File)
885            .expect("load migrated api key");
886        assert_eq!(loaded.as_deref(), Some("legacy-secret"));
887        assert!(!legacy_path.exists());
888
889        let encrypted = fs::read_to_string(storage.storage.file_path().expect("credential path"))
890            .expect("read migrated credential file");
891        assert!(!encrypted.contains("legacy-secret"));
892    }
893}