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