Skip to main content

vtcode_auth/credentials/
storage.rs

1//! Generic credential storage that orchestrates the keyring and file backends.
2
3use anyhow::{Context, Result, anyhow};
4use base64::Engine;
5use std::fs;
6
7use super::encryption;
8use super::keyring;
9use super::mode::AuthCredentialsStoreMode;
10use super::mode::ResolvedStoreMode;
11use crate::storage_paths::{auth_storage_dir, read_private_file, write_private_file};
12
13/// Generic credential storage interface.
14///
15/// Provides methods to store, load, and clear credentials using either
16/// the OS keyring or file-based storage.
17pub struct CredentialStorage {
18    service: String,
19    user: String,
20}
21
22impl CredentialStorage {
23    /// Create a new credential storage handle.
24    pub(crate) fn new(service: impl Into<String>, user: impl Into<String>) -> Self {
25        Self { service: service.into(), user: user.into() }
26    }
27
28    /// Store a credential using the specified mode.
29    pub(crate) fn store_with_mode(&self, value: &str, mode: AuthCredentialsStoreMode) -> Result<()> {
30        match mode.effective_mode() {
31            ResolvedStoreMode::Keyring => match self.store_keyring(value) {
32                Ok(()) => {
33                    if let Err(err) = self.store_file(value) {
34                        tracing::warn!(
35                            "Failed to write encrypted file backup for {}/{}: {}",
36                            self.service,
37                            self.user,
38                            err
39                        );
40                    }
41                    Ok(())
42                }
43                Err(err) => {
44                    tracing::warn!(
45                        "Failed to store credential in OS keyring for {}/{}; falling back to encrypted file storage: {}",
46                        self.service,
47                        self.user,
48                        err
49                    );
50                    self.store_file(value).context("failed to store credential in encrypted file")
51                }
52            },
53            ResolvedStoreMode::File => self.store_file(value),
54        }
55    }
56
57    /// Store a credential in exactly the selected backend.
58    ///
59    /// Provider-specific storage adapters use this boundary when their public
60    /// API promises that an operation affects only the configured backend.
61    /// Unlike [`Self::store_with_mode`], this method never falls back or writes
62    /// a backup to another backend. It takes an already-resolved backend so no
63    /// `Auto` case can reach it.
64    pub(crate) fn store_exact_with_mode(&self, value: &str, mode: ResolvedStoreMode) -> Result<()> {
65        match mode {
66            ResolvedStoreMode::Keyring => self.store_keyring(value),
67            ResolvedStoreMode::File => self.store_file(value),
68        }
69    }
70
71    /// Store a serializable value using the shared credential backends.
72    pub(crate) fn store_json<T: serde::Serialize>(&self, value: &T, mode: AuthCredentialsStoreMode) -> Result<()> {
73        let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
74        self.store_with_mode(&serialized, mode)
75    }
76
77    /// Store a serializable value in exactly the selected backend.
78    pub(crate) fn store_json_exact_with_mode<T: serde::Serialize>(
79        &self,
80        value: &T,
81        mode: ResolvedStoreMode,
82    ) -> Result<()> {
83        let serialized = serde_json::to_string(value).context("failed to serialize credential")?;
84        self.store_exact_with_mode(&serialized, mode)
85    }
86
87    /// Store a credential using `Auto` mode.
88    pub fn store(&self, value: &str) -> Result<()> {
89        self.store_with_mode(value, AuthCredentialsStoreMode::Auto)
90    }
91
92    /// Load a credential using the specified mode.
93    pub(crate) fn load_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<Option<String>> {
94        match mode.effective_mode() {
95            ResolvedStoreMode::Keyring => match self.load_keyring() {
96                Ok(Some(value)) => Ok(Some(value)),
97                Ok(None) => self.load_file(),
98                Err(err) => {
99                    tracing::warn!(
100                        "Failed to read credential from OS keyring for {}/{}; falling back to encrypted file storage: {}",
101                        self.service,
102                        self.user,
103                        err
104                    );
105                    self.load_file()
106                }
107            },
108            ResolvedStoreMode::File => self.load_file(),
109        }
110    }
111
112    /// Load a credential from exactly the selected backend.
113    ///
114    /// This deliberately does not fall back to another backend. Callers that
115    /// want a preferred-backend lookup must compose that policy explicitly.
116    pub(crate) fn load_exact_with_mode(&self, mode: ResolvedStoreMode) -> Result<Option<String>> {
117        match mode {
118            ResolvedStoreMode::Keyring => self.load_keyring(),
119            ResolvedStoreMode::File => self.load_file(),
120        }
121    }
122
123    /// Load and deserialize a value from the shared credential backends.
124    pub(crate) fn load_json<T: serde::de::DeserializeOwned>(
125        &self,
126        mode: AuthCredentialsStoreMode,
127    ) -> Result<Option<T>> {
128        let Some(serialized) = self.load_with_mode(mode)? else {
129            return Ok(None);
130        };
131        serde_json::from_str(&serialized)
132            .context("failed to deserialize credential")
133            .map(Some)
134    }
135
136    /// Load and deserialize a value from exactly the selected backend.
137    pub(crate) fn load_json_exact_with_mode<T: serde::de::DeserializeOwned>(
138        &self,
139        mode: ResolvedStoreMode,
140    ) -> Result<Option<T>> {
141        let Some(serialized) = self.load_exact_with_mode(mode)? else {
142            return Ok(None);
143        };
144        serde_json::from_str(&serialized)
145            .context("failed to deserialize credential")
146            .map(Some)
147    }
148
149    /// Load a credential using `Auto` mode.
150    pub fn load(&self) -> Result<Option<String>> {
151        self.load_with_mode(AuthCredentialsStoreMode::Auto)
152    }
153
154    /// Clear (delete) a credential using the specified mode.
155    pub(crate) fn clear_with_mode(&self, mode: AuthCredentialsStoreMode) -> Result<()> {
156        match mode.effective_mode() {
157            ResolvedStoreMode::Keyring => {
158                let mut errors = Vec::new();
159
160                if let Err(err) = self.clear_keyring() {
161                    errors.push(err.to_string());
162                }
163                if let Err(err) = self.clear_file() {
164                    errors.push(err.to_string());
165                }
166
167                if errors.is_empty() {
168                    Ok(())
169                } else {
170                    Err(anyhow!("Failed to clear credential from secure storage: {}", errors.join("; ")))
171                }
172            }
173            ResolvedStoreMode::File => self.clear_file(),
174        }
175    }
176
177    /// Clear a credential from exactly the selected backend.
178    ///
179    /// This is the deletion counterpart to [`Self::store_exact_with_mode`].
180    /// It is intentionally separate from [`Self::clear_with_mode`], whose
181    /// keyring branch also removes the encrypted backup written by the generic
182    /// storage policy.
183    pub(crate) fn clear_exact_with_mode(&self, mode: ResolvedStoreMode) -> Result<()> {
184        match mode {
185            ResolvedStoreMode::Keyring => self.clear_keyring(),
186            ResolvedStoreMode::File => self.clear_file(),
187        }
188    }
189
190    /// Clear a credential using `Auto` mode.
191    pub fn clear(&self) -> Result<()> {
192        self.clear_with_mode(AuthCredentialsStoreMode::Auto)
193    }
194
195    // ------------------------------------------------------------------
196    // Private backend helpers
197    // ------------------------------------------------------------------
198
199    fn store_keyring(&self, value: &str) -> Result<()> {
200        let entry = keyring::entry(&self.service, &self.user).context("Failed to access OS keyring")?;
201        entry.set_password(value).context("Failed to store credential in OS keyring")?;
202        tracing::debug!("Credential stored in OS keyring for {}/{}", self.service, self.user);
203        Ok(())
204    }
205
206    fn load_keyring(&self) -> Result<Option<String>> {
207        let entry = match keyring::entry(&self.service, &self.user) {
208            Ok(e) => e,
209            Err(_) => return Ok(None),
210        };
211
212        match entry.get_password() {
213            Ok(value) => Ok(Some(value)),
214            Err(keyring_core::Error::NoEntry) => Ok(None),
215            Err(e) => Err(anyhow!("Failed to read from keyring: {e}")),
216        }
217    }
218
219    fn clear_keyring(&self) -> Result<()> {
220        let entry = match keyring::entry(&self.service, &self.user) {
221            Ok(e) => e,
222            Err(_) => return Ok(()),
223        };
224
225        match entry.delete_credential() {
226            Ok(_) => {
227                tracing::debug!("Credential cleared from keyring for {}/{}", self.service, self.user);
228            }
229            Err(keyring_core::Error::NoEntry) => {}
230            Err(e) => return Err(anyhow!("Failed to clear keyring entry: {e}")),
231        }
232
233        Ok(())
234    }
235
236    fn store_file(&self, value: &str) -> Result<()> {
237        let path = self.file_path()?;
238        let encrypted = encryption::encrypt(value)?;
239        let payload = serde_json::to_vec_pretty(&encrypted).context("failed to serialize encrypted credential")?;
240        write_private_file(&path, &payload).context("failed to write encrypted credential file")?;
241        Ok(())
242    }
243
244    fn load_file(&self) -> Result<Option<String>> {
245        let path = self.file_path()?;
246        let Some(data) = read_private_file(&path).context("failed to read encrypted credential file")? else {
247            return Ok(None);
248        };
249
250        let encrypted: encryption::EncryptedCredential =
251            serde_json::from_slice(&data).context("failed to decode encrypted credential file")?;
252        encryption::decrypt(&encrypted).map(Some)
253    }
254
255    fn clear_file(&self) -> Result<()> {
256        let path = self.file_path()?;
257        match fs::remove_file(path) {
258            Ok(()) => Ok(()),
259            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
260            Err(err) => Err(anyhow!("failed to delete encrypted credential file: {err}")),
261        }
262    }
263
264    fn file_path(&self) -> Result<std::path::PathBuf> {
265        use sha2::Digest as _;
266
267        let mut hasher = sha2::Sha256::new();
268        hasher.update(self.service.as_bytes());
269        hasher.update([0]);
270        hasher.update(self.user.as_bytes());
271        let digest = hasher.finalize();
272        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
273
274        Ok(auth_storage_dir()?.join(format!("credential_{encoded}.json")))
275    }
276
277    #[cfg(test)]
278    pub(crate) fn file_path_for_tests(&self) -> Result<std::path::PathBuf> {
279        self.file_path()
280    }
281}