use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use boxlite_shared::errors::BoxliteError;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::BoxliteResult;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryCredential {
pub id: String,
pub registry_host: String,
pub repository_prefix: String,
pub username: String,
pub created_by: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Clone)]
pub struct NewRegistryCredential {
pub registry_host: String,
pub repository_prefix: Option<String>,
pub username: String,
pub password: String,
}
impl fmt::Debug for NewRegistryCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NewRegistryCredential")
.field("registry_host", &self.registry_host)
.field("repository_prefix", &self.repository_prefix)
.field("username", &self.username)
.field("password", &"[redacted]")
.finish()
}
}
#[expect(
clippy::double_must_use,
reason = "async_trait generates a must-use boxed future"
)]
#[async_trait]
pub(crate) trait RegistryBackend: Send + Sync {
async fn list_registries(&self) -> BoxliteResult<Vec<RegistryCredential>>;
async fn create_registry(
&self,
credential: &NewRegistryCredential,
) -> BoxliteResult<RegistryCredential>;
async fn remove_registry(&self, id: &str) -> BoxliteResult<()>;
}
fn checked_id(id: &str) -> BoxliteResult<&str> {
if id.len() == 36 && uuid::Uuid::try_parse(id).is_ok() {
return Ok(id);
}
Err(BoxliteError::InvalidArgument(format!(
"a registry login id is a UUID, got {id:?}"
)))
}
#[derive(Clone)]
pub struct RegistryHandle {
backend: Arc<dyn RegistryBackend>,
}
impl RegistryHandle {
pub(crate) fn new(backend: Arc<dyn RegistryBackend>) -> Self {
Self { backend }
}
pub async fn list(&self) -> BoxliteResult<Vec<RegistryCredential>> {
self.backend.list_registries().await
}
pub async fn create(
&self,
credential: &NewRegistryCredential,
) -> BoxliteResult<RegistryCredential> {
self.backend.create_registry(credential).await
}
pub async fn remove(&self, id: &str) -> BoxliteResult<()> {
self.backend.remove_registry(checked_id(id)?).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_login_prints_without_its_password() {
let login = NewRegistryCredential {
registry_host: "ghcr.io".into(),
repository_prefix: Some("acme/".into()),
username: "acme-bot".into(),
password: "ghp_not-a-real-token".into(),
};
let printed = format!("{login:?}");
assert!(!printed.contains("ghp_not-a-real-token"), "{printed}");
assert!(printed.contains("acme-bot"), "{printed}");
}
#[test]
fn checked_id_takes_a_hyphenated_uuid_only() {
let id = "0aaa0000-0000-4000-8000-000000000001";
assert_eq!(checked_id(id).unwrap(), id);
for not_an_id in [
"",
"x",
"../images",
"0aaa0000-0000-4000-8000-00000000000g",
"0aaa0000000040008000000000000001",
"{0aaa0000-0000-4000-8000-000000000001}",
] {
let error = checked_id(not_an_id).unwrap_err();
assert!(
matches!(error, BoxliteError::InvalidArgument(_)),
"{not_an_id:?}: {error}"
);
}
}
}