Skip to main content

zlayer_secrets/
git_credentials.rs

1//! Typed credential store for Git authentication (PAT or SSH key).
2//!
3//! Built on top of any [`SecretsStore`] implementation, this module provides
4//! structured storage for Git credentials. Metadata (name, kind) is stored as
5//! JSON in the `git_credentials_meta` scope, while the actual PAT or SSH key
6//! is stored as a secret in the `git_credentials` scope. Both are keyed by a
7//! UUID identifier.
8//!
9//! # Example
10//!
11//! ```rust,ignore
12//! use zlayer_secrets::{EncryptionKey, PersistentSecretsStore};
13//! use zlayer_secrets::git_credentials::{GitCredentialStore, GitCredentialKind};
14//!
15//! # async fn example() -> zlayer_secrets::Result<()> {
16//! let key = EncryptionKey::generate();
17//! let secrets_dir = zlayer_paths::ZLayerDirs::system_default().secrets();
18//! let store = PersistentSecretsStore::open(&secrets_dir, key).await?;
19//! let git_store = GitCredentialStore::new(store);
20//!
21//! let cred = git_store.create("GitHub PAT for ci", "ghp_xxxx", GitCredentialKind::Pat).await?;
22//! let value = git_store.get_value(&cred.id).await?;
23//! assert_eq!(value.expose(), "ghp_xxxx");
24//! # Ok(())
25//! # }
26//! ```
27
28use tracing::{debug, info};
29use uuid::Uuid;
30
31use crate::{Result, Secret, SecretsError, SecretsStore};
32
33pub use zlayer_types::secrets::git::{GitCredential, GitCredentialKind};
34
35/// Scope used for storing Git credential secrets (PAT / SSH key).
36const GIT_CRED_SCOPE: &str = "git_credentials";
37
38/// Scope used for storing Git credential metadata (JSON).
39const GIT_CRED_META_SCOPE: &str = "git_credentials_meta";
40
41/// Store for Git authentication credentials.
42///
43/// Wraps a [`SecretsStore`] and organises data into two scopes:
44/// - **`git_credentials_meta`**: JSON-serialised [`GitCredential`] metadata
45///   (everything except the secret value).
46/// - **`git_credentials`**: The raw PAT or SSH key as an encrypted secret.
47pub 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    /// Create a new Git credential store backed by the provided secrets store.
61    pub fn new(store: S) -> Self {
62        Self { store }
63    }
64
65    /// Store a new Git credential.
66    ///
67    /// Generates a UUID for the credential, stores the PAT/SSH key as an
68    /// encrypted secret in the `git_credentials` scope, and the metadata as
69    /// JSON in the `git_credentials_meta` scope.
70    ///
71    /// # Arguments
72    /// * `name` - Human-readable label (e.g. `"GitHub PAT for ci"`)
73    /// * `value` - The PAT or SSH key content (stored encrypted)
74    /// * `kind` - Whether this is a PAT or SSH key
75    ///
76    /// # Errors
77    /// Returns a [`SecretsError`] if serialisation or storage fails.
78    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        // Store metadata as JSON.
93        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        // Store the PAT / SSH key as an encrypted secret.
100        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    /// Retrieve credential metadata (without the secret value).
109    ///
110    /// Returns `None` if no credential with the given `id` exists.
111    ///
112    /// # Errors
113    /// Returns a [`SecretsError`] on storage/decryption errors.
114    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    /// Retrieve the PAT or SSH key for a Git credential.
131    ///
132    /// # Errors
133    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
134    pub async fn get_value(&self, id: &str) -> Result<Secret> {
135        self.store.get_secret(GIT_CRED_SCOPE, id).await
136    }
137
138    /// List all Git credentials (metadata only, no secret values).
139    ///
140    /// # Errors
141    /// Returns a [`SecretsError`] on storage/decryption errors.
142    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    /// Delete a Git credential and its associated secret.
156    ///
157    /// Both the metadata and the secret value are removed.
158    ///
159    /// # Errors
160    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
161    pub async fn delete(&self, id: &str) -> Result<()> {
162        // Delete metadata first; if it doesn't exist, the whole credential is missing.
163        self.store.delete_secret(GIT_CRED_META_SCOPE, id).await?;
164
165        // Delete the secret value. Ignore NotFound here in case only metadata
166        // existed (defensive).
167        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        // Different UUIDs, both accessible.
286        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}