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 serde::{Deserialize, Serialize};
29use tracing::{debug, info};
30use uuid::Uuid;
31
32use crate::{Result, Secret, SecretsError, SecretsStore};
33
34/// Scope used for storing Git credential secrets (PAT / SSH key).
35const GIT_CRED_SCOPE: &str = "git_credentials";
36
37/// Scope used for storing Git credential metadata (JSON).
38const GIT_CRED_META_SCOPE: &str = "git_credentials_meta";
39
40/// Git authentication credential metadata.
41///
42/// The actual PAT or SSH key is stored separately as a [`Secret`] in the
43/// `git_credentials` scope, keyed by [`id`](GitCredential::id).
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GitCredential {
46    /// Unique identifier (UUID v4).
47    pub id: String,
48    /// Human-readable display label, e.g. `"GitHub PAT for ci"`.
49    pub name: String,
50    /// Whether this credential is a personal access token or an SSH key.
51    pub kind: GitCredentialKind,
52}
53
54/// The kind of Git credential.
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57pub enum GitCredentialKind {
58    /// Personal access token.
59    Pat,
60    /// SSH private key.
61    SshKey,
62}
63
64/// Store for Git authentication credentials.
65///
66/// Wraps a [`SecretsStore`] and organises data into two scopes:
67/// - **`git_credentials_meta`**: JSON-serialised [`GitCredential`] metadata
68///   (everything except the secret value).
69/// - **`git_credentials`**: The raw PAT or SSH key as an encrypted secret.
70pub 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    /// Create a new Git credential store backed by the provided secrets store.
84    pub fn new(store: S) -> Self {
85        Self { store }
86    }
87
88    /// Store a new Git credential.
89    ///
90    /// Generates a UUID for the credential, stores the PAT/SSH key as an
91    /// encrypted secret in the `git_credentials` scope, and the metadata as
92    /// JSON in the `git_credentials_meta` scope.
93    ///
94    /// # Arguments
95    /// * `name` - Human-readable label (e.g. `"GitHub PAT for ci"`)
96    /// * `value` - The PAT or SSH key content (stored encrypted)
97    /// * `kind` - Whether this is a PAT or SSH key
98    ///
99    /// # Errors
100    /// Returns a [`SecretsError`] if serialisation or storage fails.
101    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        // Store metadata as JSON.
116        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        // Store the PAT / SSH key as an encrypted secret.
123        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    /// Retrieve credential metadata (without the secret value).
132    ///
133    /// Returns `None` if no credential with the given `id` exists.
134    ///
135    /// # Errors
136    /// Returns a [`SecretsError`] on storage/decryption errors.
137    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    /// Retrieve the PAT or SSH key for a Git credential.
154    ///
155    /// # Errors
156    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
157    pub async fn get_value(&self, id: &str) -> Result<Secret> {
158        self.store.get_secret(GIT_CRED_SCOPE, id).await
159    }
160
161    /// List all Git credentials (metadata only, no secret values).
162    ///
163    /// # Errors
164    /// Returns a [`SecretsError`] on storage/decryption errors.
165    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    /// Delete a Git credential and its associated secret.
179    ///
180    /// Both the metadata and the secret value are removed.
181    ///
182    /// # Errors
183    /// Returns [`SecretsError::NotFound`] if the credential does not exist.
184    pub async fn delete(&self, id: &str) -> Result<()> {
185        // Delete metadata first; if it doesn't exist, the whole credential is missing.
186        self.store.delete_secret(GIT_CRED_META_SCOPE, id).await?;
187
188        // Delete the secret value. Ignore NotFound here in case only metadata
189        // existed (defensive).
190        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        // Different UUIDs, both accessible.
306        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}