1use serde::{Deserialize, Serialize};
29use tracing::{debug, info};
30use uuid::Uuid;
31
32use crate::{Result, Secret, SecretsError, SecretsStore};
33
34const GIT_CRED_SCOPE: &str = "git_credentials";
36
37const GIT_CRED_META_SCOPE: &str = "git_credentials_meta";
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GitCredential {
46 pub id: String,
48 pub name: String,
50 pub kind: GitCredentialKind,
52}
53
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum GitCredentialKind {
58 Pat,
60 SshKey,
62}
63
64pub struct GitCredentialStore<S: SecretsStore> {
71 store: S,
72}
73
74impl<S: SecretsStore> std::fmt::Debug for GitCredentialStore<S> {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("GitCredentialStore")
77 .field("store", &"<secrets store>")
78 .finish()
79 }
80}
81
82impl<S: SecretsStore> GitCredentialStore<S> {
83 pub fn new(store: S) -> Self {
85 Self { store }
86 }
87
88 pub async fn create(
102 &self,
103 name: &str,
104 value: &str,
105 kind: GitCredentialKind,
106 ) -> Result<GitCredential> {
107 let id = Uuid::new_v4().to_string();
108
109 let cred = GitCredential {
110 id: id.clone(),
111 name: name.to_string(),
112 kind,
113 };
114
115 let meta_json = serde_json::to_string(&cred)
117 .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
118 self.store
119 .set_secret(GIT_CRED_META_SCOPE, &id, &Secret::new(meta_json))
120 .await?;
121
122 self.store
124 .set_secret(GIT_CRED_SCOPE, &id, &Secret::new(value))
125 .await?;
126
127 info!(id = %id, name = %name, kind = ?kind, "Created git credential");
128 Ok(cred)
129 }
130
131 pub async fn get(&self, id: &str) -> Result<Option<GitCredential>> {
138 let secret = match self.store.get_secret(GIT_CRED_META_SCOPE, id).await {
139 Ok(s) => s,
140 Err(SecretsError::NotFound { .. }) => {
141 debug!(id = %id, "Git credential not found");
142 return Ok(None);
143 }
144 Err(e) => return Err(e),
145 };
146
147 let cred: GitCredential = serde_json::from_str(secret.expose())
148 .map_err(|e| SecretsError::Storage(format!("corrupt git credential '{id}': {e}")))?;
149
150 Ok(Some(cred))
151 }
152
153 pub async fn get_value(&self, id: &str) -> Result<Secret> {
158 self.store.get_secret(GIT_CRED_SCOPE, id).await
159 }
160
161 pub async fn list(&self) -> Result<Vec<GitCredential>> {
166 let metas = self.store.list_secrets(GIT_CRED_META_SCOPE).await?;
167
168 let mut creds = Vec::with_capacity(metas.len());
169 for meta in metas {
170 if let Some(cred) = self.get(&meta.name).await? {
171 creds.push(cred);
172 }
173 }
174
175 Ok(creds)
176 }
177
178 pub async fn delete(&self, id: &str) -> Result<()> {
185 self.store.delete_secret(GIT_CRED_META_SCOPE, id).await?;
187
188 match self.store.delete_secret(GIT_CRED_SCOPE, id).await {
191 Ok(()) | Err(SecretsError::NotFound { .. }) => {}
192 Err(e) => return Err(e),
193 }
194
195 info!(id = %id, "Deleted git credential");
196 Ok(())
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::{EncryptionKey, PersistentSecretsStore};
204
205 async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
206 let temp_dir = tempfile::tempdir().unwrap();
207 let db_path = temp_dir.path().join("test_git_creds.sqlite");
208 let key = EncryptionKey::generate();
209 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
210 (store, temp_dir)
211 }
212
213 #[tokio::test]
214 async fn test_create_and_get() {
215 let (store, _temp) = create_test_store().await;
216 let git_store = GitCredentialStore::new(store);
217
218 let cred = git_store
219 .create("GitHub PAT for ci", "ghp_xxxx", GitCredentialKind::Pat)
220 .await
221 .unwrap();
222
223 assert_eq!(cred.name, "GitHub PAT for ci");
224 assert_eq!(cred.kind, GitCredentialKind::Pat);
225 assert!(!cred.id.is_empty());
226
227 let retrieved = git_store.get(&cred.id).await.unwrap();
228 assert!(retrieved.is_some());
229 let retrieved = retrieved.unwrap();
230 assert_eq!(retrieved.id, cred.id);
231 assert_eq!(retrieved.name, "GitHub PAT for ci");
232 assert_eq!(retrieved.kind, GitCredentialKind::Pat);
233 }
234
235 #[tokio::test]
236 async fn test_get_value() {
237 let (store, _temp) = create_test_store().await;
238 let git_store = GitCredentialStore::new(store);
239
240 let cred = git_store
241 .create(
242 "My SSH key",
243 "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
244 GitCredentialKind::SshKey,
245 )
246 .await
247 .unwrap();
248
249 let value = git_store.get_value(&cred.id).await.unwrap();
250 assert_eq!(value.expose(), "-----BEGIN OPENSSH PRIVATE KEY-----\n...");
251 }
252
253 #[tokio::test]
254 async fn test_list() {
255 let (store, _temp) = create_test_store().await;
256 let git_store = GitCredentialStore::new(store);
257
258 git_store
259 .create("PAT 1", "token1", GitCredentialKind::Pat)
260 .await
261 .unwrap();
262 git_store
263 .create("SSH key 1", "key1", GitCredentialKind::SshKey)
264 .await
265 .unwrap();
266
267 let list = git_store.list().await.unwrap();
268 assert_eq!(list.len(), 2);
269
270 let names: Vec<&str> = list.iter().map(|c| c.name.as_str()).collect();
271 assert!(names.contains(&"PAT 1"));
272 assert!(names.contains(&"SSH key 1"));
273 }
274
275 #[tokio::test]
276 async fn test_delete() {
277 let (store, _temp) = create_test_store().await;
278 let git_store = GitCredentialStore::new(store);
279
280 let cred = git_store
281 .create("To delete", "token", GitCredentialKind::Pat)
282 .await
283 .unwrap();
284
285 git_store.delete(&cred.id).await.unwrap();
286
287 assert!(git_store.get(&cred.id).await.unwrap().is_none());
288 assert!(git_store.get_value(&cred.id).await.is_err());
289 }
290
291 #[tokio::test]
292 async fn test_create_multiple_same_name() {
293 let (store, _temp) = create_test_store().await;
294 let git_store = GitCredentialStore::new(store);
295
296 let cred1 = git_store
297 .create("Same name", "val1", GitCredentialKind::Pat)
298 .await
299 .unwrap();
300 let cred2 = git_store
301 .create("Same name", "val2", GitCredentialKind::Pat)
302 .await
303 .unwrap();
304
305 assert_ne!(cred1.id, cred2.id);
307
308 let v1 = git_store.get_value(&cred1.id).await.unwrap();
309 let v2 = git_store.get_value(&cred2.id).await.unwrap();
310 assert_eq!(v1.expose(), "val1");
311 assert_eq!(v2.expose(), "val2");
312 }
313
314 #[tokio::test]
315 async fn test_get_nonexistent_returns_none() {
316 let (store, _temp) = create_test_store().await;
317 let git_store = GitCredentialStore::new(store);
318
319 let result = git_store.get("nonexistent-id").await.unwrap();
320 assert!(result.is_none());
321 }
322
323 #[tokio::test]
324 async fn test_get_value_nonexistent_returns_error() {
325 let (store, _temp) = create_test_store().await;
326 let git_store = GitCredentialStore::new(store);
327
328 let result = git_store.get_value("nonexistent-id").await;
329 assert!(result.is_err());
330 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
331 }
332
333 #[tokio::test]
334 async fn test_delete_nonexistent_returns_error() {
335 let (store, _temp) = create_test_store().await;
336 let git_store = GitCredentialStore::new(store);
337
338 let result = git_store.delete("nonexistent-id").await;
339 assert!(result.is_err());
340 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
341 }
342
343 #[tokio::test]
344 async fn test_serde_credential_kind_roundtrip() {
345 let pat = serde_json::to_string(&GitCredentialKind::Pat).unwrap();
346 assert_eq!(pat, "\"pat\"");
347
348 let ssh = serde_json::to_string(&GitCredentialKind::SshKey).unwrap();
349 assert_eq!(ssh, "\"ssh_key\"");
350
351 let parsed: GitCredentialKind = serde_json::from_str(&pat).unwrap();
352 assert_eq!(parsed, GitCredentialKind::Pat);
353
354 let parsed: GitCredentialKind = serde_json::from_str(&ssh).unwrap();
355 assert_eq!(parsed, GitCredentialKind::SshKey);
356 }
357}