1use serde::{Deserialize, Serialize};
29use tracing::{debug, info};
30use uuid::Uuid;
31
32use crate::{Result, Secret, SecretsError, SecretsStore};
33
34const REGISTRY_CRED_SCOPE: &str = "registry_credentials";
36
37const REGISTRY_CRED_META_SCOPE: &str = "registry_credentials_meta";
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct RegistryCredential {
46 pub id: String,
48 pub registry: String,
50 pub username: String,
52 pub auth_type: RegistryAuthType,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "snake_case")]
59pub enum RegistryAuthType {
60 Basic,
62 Token,
64}
65
66pub struct RegistryCredentialStore<S: SecretsStore> {
73 store: S,
74}
75
76impl<S: SecretsStore> std::fmt::Debug for RegistryCredentialStore<S> {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_struct("RegistryCredentialStore")
79 .field("store", &"<secrets store>")
80 .finish()
81 }
82}
83
84impl<S: SecretsStore> RegistryCredentialStore<S> {
85 pub fn new(store: S) -> Self {
87 Self { store }
88 }
89
90 pub async fn create(
105 &self,
106 registry: &str,
107 username: &str,
108 password: &str,
109 auth_type: RegistryAuthType,
110 ) -> Result<RegistryCredential> {
111 let id = Uuid::new_v4().to_string();
112
113 let cred = RegistryCredential {
114 id: id.clone(),
115 registry: registry.to_string(),
116 username: username.to_string(),
117 auth_type,
118 };
119
120 let meta_json = serde_json::to_string(&cred)
122 .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
123 self.store
124 .set_secret(REGISTRY_CRED_META_SCOPE, &id, &Secret::new(meta_json))
125 .await?;
126
127 self.store
129 .set_secret(REGISTRY_CRED_SCOPE, &id, &Secret::new(password))
130 .await?;
131
132 info!(id = %id, registry = %registry, username = %username, "Created registry credential");
133 Ok(cred)
134 }
135
136 pub async fn get(&self, id: &str) -> Result<Option<RegistryCredential>> {
143 let secret = match self.store.get_secret(REGISTRY_CRED_META_SCOPE, id).await {
144 Ok(s) => s,
145 Err(SecretsError::NotFound { .. }) => {
146 debug!(id = %id, "Registry credential not found");
147 return Ok(None);
148 }
149 Err(e) => return Err(e),
150 };
151
152 let cred: RegistryCredential = serde_json::from_str(secret.expose()).map_err(|e| {
153 SecretsError::Storage(format!("corrupt registry credential '{id}': {e}"))
154 })?;
155
156 Ok(Some(cred))
157 }
158
159 pub async fn get_password(&self, id: &str) -> Result<Secret> {
164 self.store.get_secret(REGISTRY_CRED_SCOPE, id).await
165 }
166
167 pub async fn list(&self) -> Result<Vec<RegistryCredential>> {
172 let metas = self.store.list_secrets(REGISTRY_CRED_META_SCOPE).await?;
173
174 let mut creds = Vec::with_capacity(metas.len());
175 for meta in metas {
176 if let Some(cred) = self.get(&meta.name).await? {
177 creds.push(cred);
178 }
179 }
180
181 Ok(creds)
182 }
183
184 pub async fn delete(&self, id: &str) -> Result<()> {
191 self.store
193 .delete_secret(REGISTRY_CRED_META_SCOPE, id)
194 .await?;
195
196 match self.store.delete_secret(REGISTRY_CRED_SCOPE, id).await {
199 Ok(()) | Err(SecretsError::NotFound { .. }) => {}
200 Err(e) => return Err(e),
201 }
202
203 info!(id = %id, "Deleted registry credential");
204 Ok(())
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::{EncryptionKey, PersistentSecretsStore};
212
213 async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
214 let temp_dir = tempfile::tempdir().unwrap();
215 let db_path = temp_dir.path().join("test_registry_creds.sqlite");
216 let key = EncryptionKey::generate();
217 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
218 (store, temp_dir)
219 }
220
221 #[tokio::test]
222 async fn test_create_and_get() {
223 let (store, _temp) = create_test_store().await;
224 let reg_store = RegistryCredentialStore::new(store);
225
226 let cred = reg_store
227 .create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token)
228 .await
229 .unwrap();
230
231 assert_eq!(cred.registry, "ghcr.io");
232 assert_eq!(cred.username, "ci-bot");
233 assert_eq!(cred.auth_type, RegistryAuthType::Token);
234 assert!(!cred.id.is_empty());
235
236 let retrieved = reg_store.get(&cred.id).await.unwrap();
237 assert!(retrieved.is_some());
238 let retrieved = retrieved.unwrap();
239 assert_eq!(retrieved.id, cred.id);
240 assert_eq!(retrieved.registry, "ghcr.io");
241 assert_eq!(retrieved.username, "ci-bot");
242 assert_eq!(retrieved.auth_type, RegistryAuthType::Token);
243 }
244
245 #[tokio::test]
246 async fn test_get_password() {
247 let (store, _temp) = create_test_store().await;
248 let reg_store = RegistryCredentialStore::new(store);
249
250 let cred = reg_store
251 .create("docker.io", "user", "s3cret!", RegistryAuthType::Basic)
252 .await
253 .unwrap();
254
255 let password = reg_store.get_password(&cred.id).await.unwrap();
256 assert_eq!(password.expose(), "s3cret!");
257 }
258
259 #[tokio::test]
260 async fn test_list() {
261 let (store, _temp) = create_test_store().await;
262 let reg_store = RegistryCredentialStore::new(store);
263
264 reg_store
265 .create("docker.io", "user1", "pw1", RegistryAuthType::Basic)
266 .await
267 .unwrap();
268 reg_store
269 .create("ghcr.io", "user2", "pw2", RegistryAuthType::Token)
270 .await
271 .unwrap();
272
273 let list = reg_store.list().await.unwrap();
274 assert_eq!(list.len(), 2);
275
276 let registries: Vec<&str> = list.iter().map(|c| c.registry.as_str()).collect();
277 assert!(registries.contains(&"docker.io"));
278 assert!(registries.contains(&"ghcr.io"));
279 }
280
281 #[tokio::test]
282 async fn test_delete() {
283 let (store, _temp) = create_test_store().await;
284 let reg_store = RegistryCredentialStore::new(store);
285
286 let cred = reg_store
287 .create("docker.io", "user", "pw", RegistryAuthType::Basic)
288 .await
289 .unwrap();
290
291 reg_store.delete(&cred.id).await.unwrap();
292
293 assert!(reg_store.get(&cred.id).await.unwrap().is_none());
294 assert!(reg_store.get_password(&cred.id).await.is_err());
295 }
296
297 #[tokio::test]
298 async fn test_create_overwrites() {
299 let (store, _temp) = create_test_store().await;
300 let reg_store = RegistryCredentialStore::new(store);
301
302 let cred1 = reg_store
303 .create("docker.io", "user", "pw1", RegistryAuthType::Basic)
304 .await
305 .unwrap();
306
307 let cred2 = reg_store
311 .create("docker.io", "user", "pw2", RegistryAuthType::Basic)
312 .await
313 .unwrap();
314
315 assert_ne!(cred1.id, cred2.id);
316
317 let pw1 = reg_store.get_password(&cred1.id).await.unwrap();
318 let pw2 = reg_store.get_password(&cred2.id).await.unwrap();
319 assert_eq!(pw1.expose(), "pw1");
320 assert_eq!(pw2.expose(), "pw2");
321 }
322
323 #[tokio::test]
324 async fn test_get_nonexistent_returns_none() {
325 let (store, _temp) = create_test_store().await;
326 let reg_store = RegistryCredentialStore::new(store);
327
328 let result = reg_store.get("nonexistent-id").await.unwrap();
329 assert!(result.is_none());
330 }
331
332 #[tokio::test]
333 async fn test_get_password_nonexistent_returns_error() {
334 let (store, _temp) = create_test_store().await;
335 let reg_store = RegistryCredentialStore::new(store);
336
337 let result = reg_store.get_password("nonexistent-id").await;
338 assert!(result.is_err());
339 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
340 }
341
342 #[tokio::test]
343 async fn test_delete_nonexistent_returns_error() {
344 let (store, _temp) = create_test_store().await;
345 let reg_store = RegistryCredentialStore::new(store);
346
347 let result = reg_store.delete("nonexistent-id").await;
348 assert!(result.is_err());
349 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
350 }
351
352 #[tokio::test]
353 async fn test_serde_auth_type_roundtrip() {
354 let basic = serde_json::to_string(&RegistryAuthType::Basic).unwrap();
355 assert_eq!(basic, "\"basic\"");
356
357 let token = serde_json::to_string(&RegistryAuthType::Token).unwrap();
358 assert_eq!(token, "\"token\"");
359
360 let parsed: RegistryAuthType = serde_json::from_str(&basic).unwrap();
361 assert_eq!(parsed, RegistryAuthType::Basic);
362
363 let parsed: RegistryAuthType = serde_json::from_str(&token).unwrap();
364 assert_eq!(parsed, RegistryAuthType::Token);
365 }
366}