zlayer_secrets/
git_credentials.rs1use tracing::{debug, info};
29use uuid::Uuid;
30
31use crate::{Result, Secret, SecretsError, SecretsStore};
32
33pub use zlayer_types::secrets::git::{GitCredential, GitCredentialKind};
34
35const GIT_CRED_SCOPE: &str = "git_credentials";
37
38const GIT_CRED_META_SCOPE: &str = "git_credentials_meta";
40
41pub struct GitCredentialStore<S: SecretsStore> {
48 store: S,
49}
50
51impl<S: SecretsStore> std::fmt::Debug for GitCredentialStore<S> {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("GitCredentialStore")
54 .field("store", &"<secrets store>")
55 .finish()
56 }
57}
58
59impl<S: SecretsStore> GitCredentialStore<S> {
60 pub fn new(store: S) -> Self {
62 Self { store }
63 }
64
65 pub async fn create(
79 &self,
80 name: &str,
81 value: &str,
82 kind: GitCredentialKind,
83 ) -> Result<GitCredential> {
84 let id = Uuid::new_v4().to_string();
85
86 let cred = GitCredential {
87 id: id.clone(),
88 name: name.to_string(),
89 kind,
90 };
91
92 let meta_json = serde_json::to_string(&cred)
94 .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
95 self.store
96 .set_secret(GIT_CRED_META_SCOPE, &id, &Secret::new(meta_json))
97 .await?;
98
99 self.store
101 .set_secret(GIT_CRED_SCOPE, &id, &Secret::new(value))
102 .await?;
103
104 info!(id = %id, name = %name, kind = ?kind, "Created git credential");
105 Ok(cred)
106 }
107
108 pub async fn get(&self, id: &str) -> Result<Option<GitCredential>> {
115 let secret = match self.store.get_secret(GIT_CRED_META_SCOPE, id).await {
116 Ok(s) => s,
117 Err(SecretsError::NotFound { .. }) => {
118 debug!(id = %id, "Git credential not found");
119 return Ok(None);
120 }
121 Err(e) => return Err(e),
122 };
123
124 let cred: GitCredential = serde_json::from_str(secret.expose())
125 .map_err(|e| SecretsError::Storage(format!("corrupt git credential '{id}': {e}")))?;
126
127 Ok(Some(cred))
128 }
129
130 pub async fn get_value(&self, id: &str) -> Result<Secret> {
135 self.store.get_secret(GIT_CRED_SCOPE, id).await
136 }
137
138 pub async fn list(&self) -> Result<Vec<GitCredential>> {
143 let metas = self.store.list_secrets(GIT_CRED_META_SCOPE).await?;
144
145 let mut creds = Vec::with_capacity(metas.len());
146 for meta in metas {
147 if let Some(cred) = self.get(&meta.name).await? {
148 creds.push(cred);
149 }
150 }
151
152 Ok(creds)
153 }
154
155 pub async fn delete(&self, id: &str) -> Result<()> {
162 self.store.delete_secret(GIT_CRED_META_SCOPE, id).await?;
164
165 match self.store.delete_secret(GIT_CRED_SCOPE, id).await {
168 Ok(()) | Err(SecretsError::NotFound { .. }) => {}
169 Err(e) => return Err(e),
170 }
171
172 info!(id = %id, "Deleted git credential");
173 Ok(())
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::{EncryptionKey, PersistentSecretsStore};
181 use zlayer_paths::ZLayerDirs;
182
183 async fn create_test_store() -> (PersistentSecretsStore, zlayer_types::Scratch) {
184 let temp_dir = ZLayerDirs::system_default()
185 .scratch_dir("create-test-store-")
186 .unwrap();
187 let db_path = temp_dir.path().join("test_git_creds.sqlite");
188 let key = EncryptionKey::generate();
189 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
190 (store, temp_dir)
191 }
192
193 #[tokio::test]
194 async fn test_create_and_get() {
195 let (store, _temp) = create_test_store().await;
196 let git_store = GitCredentialStore::new(store);
197
198 let cred = git_store
199 .create("GitHub PAT for ci", "ghp_xxxx", GitCredentialKind::Pat)
200 .await
201 .unwrap();
202
203 assert_eq!(cred.name, "GitHub PAT for ci");
204 assert_eq!(cred.kind, GitCredentialKind::Pat);
205 assert!(!cred.id.is_empty());
206
207 let retrieved = git_store.get(&cred.id).await.unwrap();
208 assert!(retrieved.is_some());
209 let retrieved = retrieved.unwrap();
210 assert_eq!(retrieved.id, cred.id);
211 assert_eq!(retrieved.name, "GitHub PAT for ci");
212 assert_eq!(retrieved.kind, GitCredentialKind::Pat);
213 }
214
215 #[tokio::test]
216 async fn test_get_value() {
217 let (store, _temp) = create_test_store().await;
218 let git_store = GitCredentialStore::new(store);
219
220 let cred = git_store
221 .create(
222 "My SSH key",
223 "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
224 GitCredentialKind::SshKey,
225 )
226 .await
227 .unwrap();
228
229 let value = git_store.get_value(&cred.id).await.unwrap();
230 assert_eq!(value.expose(), "-----BEGIN OPENSSH PRIVATE KEY-----\n...");
231 }
232
233 #[tokio::test]
234 async fn test_list() {
235 let (store, _temp) = create_test_store().await;
236 let git_store = GitCredentialStore::new(store);
237
238 git_store
239 .create("PAT 1", "token1", GitCredentialKind::Pat)
240 .await
241 .unwrap();
242 git_store
243 .create("SSH key 1", "key1", GitCredentialKind::SshKey)
244 .await
245 .unwrap();
246
247 let list = git_store.list().await.unwrap();
248 assert_eq!(list.len(), 2);
249
250 let names: Vec<&str> = list.iter().map(|c| c.name.as_str()).collect();
251 assert!(names.contains(&"PAT 1"));
252 assert!(names.contains(&"SSH key 1"));
253 }
254
255 #[tokio::test]
256 async fn test_delete() {
257 let (store, _temp) = create_test_store().await;
258 let git_store = GitCredentialStore::new(store);
259
260 let cred = git_store
261 .create("To delete", "token", GitCredentialKind::Pat)
262 .await
263 .unwrap();
264
265 git_store.delete(&cred.id).await.unwrap();
266
267 assert!(git_store.get(&cred.id).await.unwrap().is_none());
268 assert!(git_store.get_value(&cred.id).await.is_err());
269 }
270
271 #[tokio::test]
272 async fn test_create_multiple_same_name() {
273 let (store, _temp) = create_test_store().await;
274 let git_store = GitCredentialStore::new(store);
275
276 let cred1 = git_store
277 .create("Same name", "val1", GitCredentialKind::Pat)
278 .await
279 .unwrap();
280 let cred2 = git_store
281 .create("Same name", "val2", GitCredentialKind::Pat)
282 .await
283 .unwrap();
284
285 assert_ne!(cred1.id, cred2.id);
287
288 let v1 = git_store.get_value(&cred1.id).await.unwrap();
289 let v2 = git_store.get_value(&cred2.id).await.unwrap();
290 assert_eq!(v1.expose(), "val1");
291 assert_eq!(v2.expose(), "val2");
292 }
293
294 #[tokio::test]
295 async fn test_get_nonexistent_returns_none() {
296 let (store, _temp) = create_test_store().await;
297 let git_store = GitCredentialStore::new(store);
298
299 let result = git_store.get("nonexistent-id").await.unwrap();
300 assert!(result.is_none());
301 }
302
303 #[tokio::test]
304 async fn test_get_value_nonexistent_returns_error() {
305 let (store, _temp) = create_test_store().await;
306 let git_store = GitCredentialStore::new(store);
307
308 let result = git_store.get_value("nonexistent-id").await;
309 assert!(result.is_err());
310 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
311 }
312
313 #[tokio::test]
314 async fn test_delete_nonexistent_returns_error() {
315 let (store, _temp) = create_test_store().await;
316 let git_store = GitCredentialStore::new(store);
317
318 let result = git_store.delete("nonexistent-id").await;
319 assert!(result.is_err());
320 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
321 }
322
323 #[tokio::test]
324 async fn test_serde_credential_kind_roundtrip() {
325 let pat = serde_json::to_string(&GitCredentialKind::Pat).unwrap();
326 assert_eq!(pat, "\"pat\"");
327
328 let ssh = serde_json::to_string(&GitCredentialKind::SshKey).unwrap();
329 assert_eq!(ssh, "\"ssh_key\"");
330
331 let parsed: GitCredentialKind = serde_json::from_str(&pat).unwrap();
332 assert_eq!(parsed, GitCredentialKind::Pat);
333
334 let parsed: GitCredentialKind = serde_json::from_str(&ssh).unwrap();
335 assert_eq!(parsed, GitCredentialKind::SshKey);
336 }
337}