1use tracing::{debug, info};
29use uuid::Uuid;
30
31use crate::{Result, Secret, SecretsError, SecretsStore};
32
33pub use zlayer_types::secrets::registry::{RegistryAuthType, RegistryCredential};
34
35const REGISTRY_CRED_SCOPE: &str = "registry_credentials";
37
38const REGISTRY_CRED_META_SCOPE: &str = "registry_credentials_meta";
40
41pub struct RegistryCredentialStore<S: SecretsStore> {
48 store: S,
49}
50
51impl<S: SecretsStore> std::fmt::Debug for RegistryCredentialStore<S> {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("RegistryCredentialStore")
54 .field("store", &"<secrets store>")
55 .finish()
56 }
57}
58
59impl<S: SecretsStore> RegistryCredentialStore<S> {
60 pub fn new(store: S) -> Self {
62 Self { store }
63 }
64
65 pub async fn create(
80 &self,
81 registry: &str,
82 username: &str,
83 password: &str,
84 auth_type: RegistryAuthType,
85 ) -> Result<RegistryCredential> {
86 let id = Uuid::new_v4().to_string();
87
88 let cred = RegistryCredential {
89 id: id.clone(),
90 registry: registry.to_string(),
91 username: username.to_string(),
92 auth_type,
93 };
94
95 let meta_json = serde_json::to_string(&cred)
97 .map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
98 self.store
99 .set_secret(REGISTRY_CRED_META_SCOPE, &id, &Secret::new(meta_json))
100 .await?;
101
102 self.store
104 .set_secret(REGISTRY_CRED_SCOPE, &id, &Secret::new(password))
105 .await?;
106
107 info!(id = %id, registry = %registry, username = %username, "Created registry credential");
108 Ok(cred)
109 }
110
111 pub async fn get(&self, id: &str) -> Result<Option<RegistryCredential>> {
118 let secret = match self.store.get_secret(REGISTRY_CRED_META_SCOPE, id).await {
119 Ok(s) => s,
120 Err(SecretsError::NotFound { .. }) => {
121 debug!(id = %id, "Registry credential not found");
122 return Ok(None);
123 }
124 Err(e) => return Err(e),
125 };
126
127 let cred: RegistryCredential = serde_json::from_str(secret.expose()).map_err(|e| {
128 SecretsError::Storage(format!("corrupt registry credential '{id}': {e}"))
129 })?;
130
131 Ok(Some(cred))
132 }
133
134 pub async fn get_password(&self, id: &str) -> Result<Secret> {
139 self.store.get_secret(REGISTRY_CRED_SCOPE, id).await
140 }
141
142 pub async fn list(&self) -> Result<Vec<RegistryCredential>> {
147 let metas = self.store.list_secrets(REGISTRY_CRED_META_SCOPE).await?;
148
149 let mut creds = Vec::with_capacity(metas.len());
150 for meta in metas {
151 if let Some(cred) = self.get(&meta.name).await? {
152 creds.push(cred);
153 }
154 }
155
156 Ok(creds)
157 }
158
159 pub async fn delete(&self, id: &str) -> Result<()> {
166 self.store
168 .delete_secret(REGISTRY_CRED_META_SCOPE, id)
169 .await?;
170
171 match self.store.delete_secret(REGISTRY_CRED_SCOPE, id).await {
174 Ok(()) | Err(SecretsError::NotFound { .. }) => {}
175 Err(e) => return Err(e),
176 }
177
178 info!(id = %id, "Deleted registry credential");
179 Ok(())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::{EncryptionKey, PersistentSecretsStore};
187
188 async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
189 let temp_dir = tempfile::tempdir().unwrap();
190 let db_path = temp_dir.path().join("test_registry_creds.sqlite");
191 let key = EncryptionKey::generate();
192 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
193 (store, temp_dir)
194 }
195
196 #[tokio::test]
197 async fn test_create_and_get() {
198 let (store, _temp) = create_test_store().await;
199 let reg_store = RegistryCredentialStore::new(store);
200
201 let cred = reg_store
202 .create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token)
203 .await
204 .unwrap();
205
206 assert_eq!(cred.registry, "ghcr.io");
207 assert_eq!(cred.username, "ci-bot");
208 assert_eq!(cred.auth_type, RegistryAuthType::Token);
209 assert!(!cred.id.is_empty());
210
211 let retrieved = reg_store.get(&cred.id).await.unwrap();
212 assert!(retrieved.is_some());
213 let retrieved = retrieved.unwrap();
214 assert_eq!(retrieved.id, cred.id);
215 assert_eq!(retrieved.registry, "ghcr.io");
216 assert_eq!(retrieved.username, "ci-bot");
217 assert_eq!(retrieved.auth_type, RegistryAuthType::Token);
218 }
219
220 #[tokio::test]
221 async fn test_get_password() {
222 let (store, _temp) = create_test_store().await;
223 let reg_store = RegistryCredentialStore::new(store);
224
225 let cred = reg_store
226 .create("docker.io", "user", "s3cret!", RegistryAuthType::Basic)
227 .await
228 .unwrap();
229
230 let password = reg_store.get_password(&cred.id).await.unwrap();
231 assert_eq!(password.expose(), "s3cret!");
232 }
233
234 #[tokio::test]
235 async fn test_list() {
236 let (store, _temp) = create_test_store().await;
237 let reg_store = RegistryCredentialStore::new(store);
238
239 reg_store
240 .create("docker.io", "user1", "pw1", RegistryAuthType::Basic)
241 .await
242 .unwrap();
243 reg_store
244 .create("ghcr.io", "user2", "pw2", RegistryAuthType::Token)
245 .await
246 .unwrap();
247
248 let list = reg_store.list().await.unwrap();
249 assert_eq!(list.len(), 2);
250
251 let registries: Vec<&str> = list.iter().map(|c| c.registry.as_str()).collect();
252 assert!(registries.contains(&"docker.io"));
253 assert!(registries.contains(&"ghcr.io"));
254 }
255
256 #[tokio::test]
257 async fn test_delete() {
258 let (store, _temp) = create_test_store().await;
259 let reg_store = RegistryCredentialStore::new(store);
260
261 let cred = reg_store
262 .create("docker.io", "user", "pw", RegistryAuthType::Basic)
263 .await
264 .unwrap();
265
266 reg_store.delete(&cred.id).await.unwrap();
267
268 assert!(reg_store.get(&cred.id).await.unwrap().is_none());
269 assert!(reg_store.get_password(&cred.id).await.is_err());
270 }
271
272 #[tokio::test]
273 async fn test_create_overwrites() {
274 let (store, _temp) = create_test_store().await;
275 let reg_store = RegistryCredentialStore::new(store);
276
277 let cred1 = reg_store
278 .create("docker.io", "user", "pw1", RegistryAuthType::Basic)
279 .await
280 .unwrap();
281
282 let cred2 = reg_store
286 .create("docker.io", "user", "pw2", RegistryAuthType::Basic)
287 .await
288 .unwrap();
289
290 assert_ne!(cred1.id, cred2.id);
291
292 let pw1 = reg_store.get_password(&cred1.id).await.unwrap();
293 let pw2 = reg_store.get_password(&cred2.id).await.unwrap();
294 assert_eq!(pw1.expose(), "pw1");
295 assert_eq!(pw2.expose(), "pw2");
296 }
297
298 #[tokio::test]
299 async fn test_get_nonexistent_returns_none() {
300 let (store, _temp) = create_test_store().await;
301 let reg_store = RegistryCredentialStore::new(store);
302
303 let result = reg_store.get("nonexistent-id").await.unwrap();
304 assert!(result.is_none());
305 }
306
307 #[tokio::test]
308 async fn test_get_password_nonexistent_returns_error() {
309 let (store, _temp) = create_test_store().await;
310 let reg_store = RegistryCredentialStore::new(store);
311
312 let result = reg_store.get_password("nonexistent-id").await;
313 assert!(result.is_err());
314 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
315 }
316
317 #[tokio::test]
318 async fn test_delete_nonexistent_returns_error() {
319 let (store, _temp) = create_test_store().await;
320 let reg_store = RegistryCredentialStore::new(store);
321
322 let result = reg_store.delete("nonexistent-id").await;
323 assert!(result.is_err());
324 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
325 }
326
327 #[tokio::test]
328 async fn test_serde_auth_type_roundtrip() {
329 let basic = serde_json::to_string(&RegistryAuthType::Basic).unwrap();
330 assert_eq!(basic, "\"basic\"");
331
332 let token = serde_json::to_string(&RegistryAuthType::Token).unwrap();
333 assert_eq!(token, "\"token\"");
334
335 let parsed: RegistryAuthType = serde_json::from_str(&basic).unwrap();
336 assert_eq!(parsed, RegistryAuthType::Basic);
337
338 let parsed: RegistryAuthType = serde_json::from_str(&token).unwrap();
339 assert_eq!(parsed, RegistryAuthType::Token);
340 }
341}