use tracing::{debug, info};
use uuid::Uuid;
use crate::{Result, Secret, SecretsError, SecretsStore};
pub use zlayer_types::secrets::registry::{RegistryAuthType, RegistryCredential};
const REGISTRY_CRED_SCOPE: &str = "registry_credentials";
const REGISTRY_CRED_META_SCOPE: &str = "registry_credentials_meta";
pub struct RegistryCredentialStore<S: SecretsStore> {
store: S,
}
impl<S: SecretsStore> std::fmt::Debug for RegistryCredentialStore<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistryCredentialStore")
.field("store", &"<secrets store>")
.finish()
}
}
impl<S: SecretsStore> RegistryCredentialStore<S> {
pub fn new(store: S) -> Self {
Self { store }
}
pub async fn create(
&self,
registry: &str,
username: &str,
password: &str,
auth_type: RegistryAuthType,
) -> Result<RegistryCredential> {
let id = Uuid::new_v4().to_string();
let cred = RegistryCredential {
id: id.clone(),
registry: registry.to_string(),
username: username.to_string(),
auth_type,
};
let meta_json = serde_json::to_string(&cred)
.map_err(|e| SecretsError::Storage(format!("failed to serialise credential: {e}")))?;
self.store
.set_secret(REGISTRY_CRED_META_SCOPE, &id, &Secret::new(meta_json))
.await?;
self.store
.set_secret(REGISTRY_CRED_SCOPE, &id, &Secret::new(password))
.await?;
info!(id = %id, registry = %registry, username = %username, "Created registry credential");
Ok(cred)
}
pub async fn get(&self, id: &str) -> Result<Option<RegistryCredential>> {
let secret = match self.store.get_secret(REGISTRY_CRED_META_SCOPE, id).await {
Ok(s) => s,
Err(SecretsError::NotFound { .. }) => {
debug!(id = %id, "Registry credential not found");
return Ok(None);
}
Err(e) => return Err(e),
};
let cred: RegistryCredential = serde_json::from_str(secret.expose()).map_err(|e| {
SecretsError::Storage(format!("corrupt registry credential '{id}': {e}"))
})?;
Ok(Some(cred))
}
pub async fn get_password(&self, id: &str) -> Result<Secret> {
self.store.get_secret(REGISTRY_CRED_SCOPE, id).await
}
pub async fn list(&self) -> Result<Vec<RegistryCredential>> {
let metas = self.store.list_secrets(REGISTRY_CRED_META_SCOPE).await?;
let mut creds = Vec::with_capacity(metas.len());
for meta in metas {
if let Some(cred) = self.get(&meta.name).await? {
creds.push(cred);
}
}
Ok(creds)
}
pub async fn delete(&self, id: &str) -> Result<()> {
self.store
.delete_secret(REGISTRY_CRED_META_SCOPE, id)
.await?;
match self.store.delete_secret(REGISTRY_CRED_SCOPE, id).await {
Ok(()) | Err(SecretsError::NotFound { .. }) => {}
Err(e) => return Err(e),
}
info!(id = %id, "Deleted registry credential");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EncryptionKey, PersistentSecretsStore};
async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("test_registry_creds.sqlite");
let key = EncryptionKey::generate();
let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
(store, temp_dir)
}
#[tokio::test]
async fn test_create_and_get() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
let cred = reg_store
.create("ghcr.io", "ci-bot", "ghp_xxxx", RegistryAuthType::Token)
.await
.unwrap();
assert_eq!(cred.registry, "ghcr.io");
assert_eq!(cred.username, "ci-bot");
assert_eq!(cred.auth_type, RegistryAuthType::Token);
assert!(!cred.id.is_empty());
let retrieved = reg_store.get(&cred.id).await.unwrap();
assert!(retrieved.is_some());
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.id, cred.id);
assert_eq!(retrieved.registry, "ghcr.io");
assert_eq!(retrieved.username, "ci-bot");
assert_eq!(retrieved.auth_type, RegistryAuthType::Token);
}
#[tokio::test]
async fn test_get_password() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
let cred = reg_store
.create("docker.io", "user", "s3cret!", RegistryAuthType::Basic)
.await
.unwrap();
let password = reg_store.get_password(&cred.id).await.unwrap();
assert_eq!(password.expose(), "s3cret!");
}
#[tokio::test]
async fn test_list() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
reg_store
.create("docker.io", "user1", "pw1", RegistryAuthType::Basic)
.await
.unwrap();
reg_store
.create("ghcr.io", "user2", "pw2", RegistryAuthType::Token)
.await
.unwrap();
let list = reg_store.list().await.unwrap();
assert_eq!(list.len(), 2);
let registries: Vec<&str> = list.iter().map(|c| c.registry.as_str()).collect();
assert!(registries.contains(&"docker.io"));
assert!(registries.contains(&"ghcr.io"));
}
#[tokio::test]
async fn test_delete() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
let cred = reg_store
.create("docker.io", "user", "pw", RegistryAuthType::Basic)
.await
.unwrap();
reg_store.delete(&cred.id).await.unwrap();
assert!(reg_store.get(&cred.id).await.unwrap().is_none());
assert!(reg_store.get_password(&cred.id).await.is_err());
}
#[tokio::test]
async fn test_create_overwrites() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
let cred1 = reg_store
.create("docker.io", "user", "pw1", RegistryAuthType::Basic)
.await
.unwrap();
let cred2 = reg_store
.create("docker.io", "user", "pw2", RegistryAuthType::Basic)
.await
.unwrap();
assert_ne!(cred1.id, cred2.id);
let pw1 = reg_store.get_password(&cred1.id).await.unwrap();
let pw2 = reg_store.get_password(&cred2.id).await.unwrap();
assert_eq!(pw1.expose(), "pw1");
assert_eq!(pw2.expose(), "pw2");
}
#[tokio::test]
async fn test_get_nonexistent_returns_none() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
let result = reg_store.get("nonexistent-id").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_get_password_nonexistent_returns_error() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
let result = reg_store.get_password("nonexistent-id").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
}
#[tokio::test]
async fn test_delete_nonexistent_returns_error() {
let (store, _temp) = create_test_store().await;
let reg_store = RegistryCredentialStore::new(store);
let result = reg_store.delete("nonexistent-id").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
}
#[tokio::test]
async fn test_serde_auth_type_roundtrip() {
let basic = serde_json::to_string(&RegistryAuthType::Basic).unwrap();
assert_eq!(basic, "\"basic\"");
let token = serde_json::to_string(&RegistryAuthType::Token).unwrap();
assert_eq!(token, "\"token\"");
let parsed: RegistryAuthType = serde_json::from_str(&basic).unwrap();
assert_eq!(parsed, RegistryAuthType::Basic);
let parsed: RegistryAuthType = serde_json::from_str(&token).unwrap();
assert_eq!(parsed, RegistryAuthType::Token);
}
}