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 use zlayer_paths::ZLayerDirs;
188
189 async fn create_test_store() -> (PersistentSecretsStore, zlayer_types::Scratch) {
190 let temp_dir = ZLayerDirs::system_default()
191 .scratch_dir("create-test-store-")
192 .unwrap();
193 let db_path = temp_dir.path().join("test_registry_creds.sqlite");
194 let key = EncryptionKey::generate();
195 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
196 (store, temp_dir)
197 }
198
199 #[tokio::test]
200 async fn test_create_and_get() {
201 let (store, _temp) = create_test_store().await;
202 let reg_store = RegistryCredentialStore::new(store);
203
204 let cred = reg_store
205 .create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token)
206 .await
207 .unwrap();
208
209 assert_eq!(cred.registry, "ghcr.io");
210 assert_eq!(cred.username, "ci-bot");
211 assert_eq!(cred.auth_type, RegistryAuthType::Token);
212 assert!(!cred.id.is_empty());
213
214 let retrieved = reg_store.get(&cred.id).await.unwrap();
215 assert!(retrieved.is_some());
216 let retrieved = retrieved.unwrap();
217 assert_eq!(retrieved.id, cred.id);
218 assert_eq!(retrieved.registry, "ghcr.io");
219 assert_eq!(retrieved.username, "ci-bot");
220 assert_eq!(retrieved.auth_type, RegistryAuthType::Token);
221 }
222
223 #[tokio::test]
224 async fn test_get_password() {
225 let (store, _temp) = create_test_store().await;
226 let reg_store = RegistryCredentialStore::new(store);
227
228 let cred = reg_store
229 .create("docker.io", "user", "s3cret!", RegistryAuthType::Basic)
230 .await
231 .unwrap();
232
233 let password = reg_store.get_password(&cred.id).await.unwrap();
234 assert_eq!(password.expose(), "s3cret!");
235 }
236
237 #[tokio::test]
238 async fn test_list() {
239 let (store, _temp) = create_test_store().await;
240 let reg_store = RegistryCredentialStore::new(store);
241
242 reg_store
243 .create("docker.io", "user1", "pw1", RegistryAuthType::Basic)
244 .await
245 .unwrap();
246 reg_store
247 .create("ghcr.io", "user2", "pw2", RegistryAuthType::Token)
248 .await
249 .unwrap();
250
251 let list = reg_store.list().await.unwrap();
252 assert_eq!(list.len(), 2);
253
254 let registries: Vec<&str> = list.iter().map(|c| c.registry.as_str()).collect();
255 assert!(registries.contains(&"docker.io"));
256 assert!(registries.contains(&"ghcr.io"));
257 }
258
259 #[tokio::test]
260 async fn test_delete() {
261 let (store, _temp) = create_test_store().await;
262 let reg_store = RegistryCredentialStore::new(store);
263
264 let cred = reg_store
265 .create("docker.io", "user", "pw", RegistryAuthType::Basic)
266 .await
267 .unwrap();
268
269 reg_store.delete(&cred.id).await.unwrap();
270
271 assert!(reg_store.get(&cred.id).await.unwrap().is_none());
272 assert!(reg_store.get_password(&cred.id).await.is_err());
273 }
274
275 #[tokio::test]
276 async fn test_create_overwrites() {
277 let (store, _temp) = create_test_store().await;
278 let reg_store = RegistryCredentialStore::new(store);
279
280 let cred1 = reg_store
281 .create("docker.io", "user", "pw1", RegistryAuthType::Basic)
282 .await
283 .unwrap();
284
285 let cred2 = reg_store
289 .create("docker.io", "user", "pw2", RegistryAuthType::Basic)
290 .await
291 .unwrap();
292
293 assert_ne!(cred1.id, cred2.id);
294
295 let pw1 = reg_store.get_password(&cred1.id).await.unwrap();
296 let pw2 = reg_store.get_password(&cred2.id).await.unwrap();
297 assert_eq!(pw1.expose(), "pw1");
298 assert_eq!(pw2.expose(), "pw2");
299 }
300
301 #[tokio::test]
302 async fn test_get_nonexistent_returns_none() {
303 let (store, _temp) = create_test_store().await;
304 let reg_store = RegistryCredentialStore::new(store);
305
306 let result = reg_store.get("nonexistent-id").await.unwrap();
307 assert!(result.is_none());
308 }
309
310 #[tokio::test]
311 async fn test_get_password_nonexistent_returns_error() {
312 let (store, _temp) = create_test_store().await;
313 let reg_store = RegistryCredentialStore::new(store);
314
315 let result = reg_store.get_password("nonexistent-id").await;
316 assert!(result.is_err());
317 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
318 }
319
320 #[tokio::test]
321 async fn test_delete_nonexistent_returns_error() {
322 let (store, _temp) = create_test_store().await;
323 let reg_store = RegistryCredentialStore::new(store);
324
325 let result = reg_store.delete("nonexistent-id").await;
326 assert!(result.is_err());
327 assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
328 }
329
330 #[tokio::test]
331 async fn test_serde_auth_type_roundtrip() {
332 let basic = serde_json::to_string(&RegistryAuthType::Basic).unwrap();
333 assert_eq!(basic, "\"basic\"");
334
335 let token = serde_json::to_string(&RegistryAuthType::Token).unwrap();
336 assert_eq!(token, "\"token\"");
337
338 let parsed: RegistryAuthType = serde_json::from_str(&basic).unwrap();
339 assert_eq!(parsed, RegistryAuthType::Basic);
340
341 let parsed: RegistryAuthType = serde_json::from_str(&token).unwrap();
342 assert_eq!(parsed, RegistryAuthType::Token);
343 }
344}