use crate::io::api::{self, EmptyField, Endpoint, Param, RemoteResource};
use crate::io::config::RegistryProfile;
use crate::io::ApiResult;
use crate::param;
use crate::With;
use acorn_core::prelude::{String, ToString, Vec};
use color_eyre::eyre::eyre;
use core::{fmt, iter::once};
use data_encoding::BASE64;
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use std::env;
use urlencoding::encode;
const PAGE_SIZE: usize = 100;
const MAX_PAGES: usize = 100;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HarborError {
Authentication,
Forbidden,
NotFound,
RateLimited,
Request(String),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Artifact {
pub digest: String,
#[serde(default)]
pub media_type: Option<String>,
#[serde(default)]
pub artifact_type: Option<String>,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub push_time: Option<String>,
#[serde(default)]
pub tags: Vec<Tag>,
#[serde(default)]
pub labels: Vec<Label>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Tag {
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Label {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Clone, Debug, With)]
pub struct Options<'a> {
#[with(skip)]
profile: &'a RegistryProfile,
#[with(skip)]
pub(crate) project: &'a str,
#[with(skip)]
pub(crate) repository: &'a str,
#[with(public, some)]
reference: Option<&'a str>,
}
impl fmt::Display for HarborError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
| Self::Authentication => write!(formatter, "Harbor authentication failed"),
| Self::Forbidden => write!(formatter, "Harbor access is forbidden"),
| Self::NotFound => write!(formatter, "Harbor artifact was not found"),
| Self::RateLimited => write!(formatter, "Harbor request was rate limited"),
| Self::Request(message) => write!(formatter, "Harbor request failed — {message}"),
}
}
}
impl core::error::Error for HarborError {}
impl<'a> Options<'a> {
pub fn from_repository(profile: &'a RegistryProfile, repository: &'a str) -> ApiResult<Self> {
split_repository(repository).map(|(project, repository)| Self {
profile,
project,
repository,
reference: None,
})
}
}
pub async fn health(profile: &RegistryProfile) -> ApiResult<()> {
match (endpoint(profile), params(profile, Vec::new())) {
| (Ok(endpoint), Ok(params)) => endpoint
.invoke_with::<EmptyField, EmptyField>("health", Some(params))
.await
.map(|_| ())
.map_err(|why| eyre!(HarborError::Request(why.to_string()))),
| (Err(why), _) | (_, Err(why)) => Err(why),
}
}
pub async fn artifact(options: &Options<'_>) -> ApiResult<Artifact> {
match options.reference {
| Some(reference) => {
let project = encode(options.project);
let repository = encode(options.repository);
let reference = encode(reference);
let values = vec![
param!(TemplateValue, "project", project.as_ref()),
param!(TemplateValue, "repository", repository.as_ref()),
param!(TemplateValue, "reference", reference.as_ref()),
];
match (endpoint(options.profile), params(options.profile, values)) {
| (Ok(endpoint), Ok(params)) => {
let response = endpoint.invoke_with::<EmptyField, EmptyField>("artifact", Some(params)).await;
endpoint
.handle::<Artifact>(response)
.map_err(|why| eyre!(HarborError::Request(format!("Invalid response for 'artifact' — {why}"))))
}
| (Err(why), _) | (_, Err(why)) => Err(why),
}
}
| None => Err(eyre!("Harbor artifact request requires a tag or digest reference")),
}
}
pub async fn artifacts(options: &Options<'_>) -> ApiResult<Vec<Artifact>> {
let mut artifacts = Vec::new();
for page in 1..=MAX_PAGES {
let page_value = page.to_string();
let page_size = PAGE_SIZE.to_string();
let project_value = encode(options.project);
let repository_value = encode(options.repository);
let values = vec![
param!(TemplateValue, "project", project_value.as_ref()),
param!(TemplateValue, "repository", repository_value.as_ref()),
param!(KeyValuePair, "page", page_value.as_str()),
param!(KeyValuePair, "page_size", page_size.as_str()),
param!(KeyValuePair, "with_tag", "true"),
param!(KeyValuePair, "with_label", "true"),
];
let page_artifacts = match (endpoint(options.profile), params(options.profile, values)) {
| (Ok(endpoint), Ok(params)) => {
let response = endpoint.invoke_with::<EmptyField, EmptyField>("artifacts", Some(params)).await;
endpoint
.handle::<Vec<Artifact>>(response)
.map_err(|why| eyre!(HarborError::Request(format!("Invalid response for 'artifacts' — {why}"))))
}
| (Err(why), _) | (_, Err(why)) => Err(why),
};
match page_artifacts {
| Ok(values) => {
let is_last_page = values.len() < PAGE_SIZE;
artifacts.extend(values);
if is_last_page {
return Ok(artifacts);
}
}
| Err(why) => return Err(why),
}
}
Err(eyre!("Harbor artifact pagination exceeded {MAX_PAGES} pages"))
}
pub(crate) fn endpoint(profile: &RegistryProfile) -> ApiResult<Endpoint> {
let endpoint_uri = profile.endpoint.trim_end_matches('/');
match endpoint_uri.starts_with("https://") || profile.plain_http && endpoint_uri.starts_with("http://") {
| true => Endpoint::from_template("harbor::api").map(|endpoint| endpoint.with_domain(endpoint_uri)),
| false => Err(eyre!("Harbor endpoint must use HTTPS (plain HTTP is restricted to loopback development)")),
}
}
pub(crate) fn params(profile: &RegistryProfile, params: Vec<Param>) -> ApiResult<Vec<Param>> {
match (&profile.credential_env, &profile.username) {
| (Some(name), username) => match env::var(name) {
| Ok(secret) => {
let secret = api::Secret::from(secret);
let exposed = ExposeSecret::expose_secret(&secret);
let authorization = match username {
| Some(username) => {
let credentials = format!("{username}:{exposed}");
format!("Basic {}", BASE64.encode(credentials.as_bytes()))
}
| None => format!("Bearer {exposed}"),
};
Ok(params
.into_iter()
.chain(once(param!(Header, "Authorization", authorization.as_str())))
.collect())
}
| Err(_) => Err(eyre!("Harbor credential environment variable '{name}' is not set")),
},
| (None, _) => Ok(params),
}
}
fn split_repository(repository: &str) -> ApiResult<(&str, &str)> {
repository
.split_once('/')
.filter(|(project, repository)| !project.is_empty() && !repository.is_empty())
.ok_or_else(|| eyre!("Harbor repository must include a project and repository path"))
}
#[cfg(test)]
mod tests;