use async_trait::async_trait;
use std::sync::Arc;
use boxlite_shared::errors::BoxliteError;
use crate::BoxliteResult;
use crate::images::ImageObject;
use crate::runtime::types::{ImageDetail, ImageInfo, ImageUsage};
const MAX_NAME_CHARS: usize = 512;
const MAX_ALIAS_CHARS: usize = 128;
#[allow(
clippy::double_must_use,
reason = "async_trait generates a must-use boxed future"
)]
#[async_trait]
pub(crate) trait ImageBackend: Send + Sync {
async fn pull_image(&self, image_ref: &str) -> BoxliteResult<ImageObject>;
async fn list_images(&self) -> BoxliteResult<Vec<ImageInfo>>;
async fn get_image(&self, name: &str) -> BoxliteResult<ImageDetail>;
async fn remove_image(&self, name: &str) -> BoxliteResult<()>;
async fn image_usage(&self) -> BoxliteResult<ImageUsage>;
async fn create_alias(
&self,
name: &str,
alias: &str,
digest: &str,
) -> BoxliteResult<ImageDetail>;
async fn delete_alias(&self, name: &str, alias: &str) -> BoxliteResult<()>;
}
pub(crate) fn checked_name(name: &str) -> BoxliteResult<&str> {
let refuse = |why: String| Err(BoxliteError::InvalidArgument(why));
if name.is_empty() || name.trim() != name {
return refuse("an image name must be non-empty, without surrounding whitespace".into());
}
if name.chars().count() > MAX_NAME_CHARS {
return refuse(format!(
"an image name must not exceed {MAX_NAME_CHARS} characters"
));
}
if name.chars().any(|c| c < '\u{20}' || c == '\u{7f}') {
return refuse("an image name must not contain control characters".into());
}
let is_traversal = name.starts_with('/')
|| name
.replace('\\', "/")
.split('/')
.any(|part| part == "." || part == "..");
if is_traversal || matches!(name, "usage" | "registries") {
return refuse(format!("'{name}' is not an image name"));
}
let untagged = untagged(name);
if untagged != name {
return refuse(format!(
"'{name}' names a tag or digest; pass the image name alone, such as '{untagged}'"
));
}
Ok(name)
}
pub(crate) fn checked_alias(tag: &str) -> BoxliteResult<&str> {
let mut chars = tag.chars();
let starts_well = chars
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
let continues_well = chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'));
if starts_well && continues_well && tag.len() <= MAX_ALIAS_CHARS {
Ok(tag)
} else {
Err(BoxliteError::InvalidArgument(format!(
"'{tag}' is not a tag the OCI distribution spec accepts"
)))
}
}
pub(crate) fn checked_digest(digest: &str) -> BoxliteResult<&str> {
match digest.strip_prefix("sha256:") {
Some(hex)
if hex.len() == 64 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) =>
{
Ok(digest)
}
_ => Err(BoxliteError::InvalidArgument(format!(
"'{digest}' is not a sha256 digest; pass the digest of one of the image's versions"
))),
}
}
fn untagged(reference: &str) -> &str {
let reference = reference.split('@').next().unwrap_or(reference);
match reference.rsplit_once(':') {
Some((name, tag)) if !tag.contains('/') => name,
_ => reference,
}
}
#[derive(Clone)]
pub struct ImageHandle {
manager: Arc<dyn ImageBackend>,
registry_backend: Option<Arc<dyn super::registries::RegistryBackend>>,
}
impl ImageHandle {
pub(crate) fn new(
manager: Arc<dyn ImageBackend>,
registry_backend: Option<Arc<dyn super::registries::RegistryBackend>>,
) -> Self {
Self {
manager,
registry_backend,
}
}
pub fn registries(&self) -> BoxliteResult<super::RegistryHandle> {
match &self.registry_backend {
Some(backend) => Ok(super::RegistryHandle::new(Arc::clone(backend))),
None => Err(BoxliteError::Unsupported(
"registry logins require a REST image catalog; local runtimes use image_registries in their options".into(),
)),
}
}
pub async fn pull(&self, image_ref: &str) -> BoxliteResult<ImageObject> {
self.manager.pull_image(image_ref).await
}
pub async fn list(&self) -> BoxliteResult<Vec<ImageInfo>> {
self.manager.list_images().await
}
pub async fn get(&self, name: &str) -> BoxliteResult<ImageDetail> {
self.manager.get_image(checked_name(name)?).await
}
pub async fn remove(&self, name: &str) -> BoxliteResult<()> {
self.manager.remove_image(checked_name(name)?).await
}
pub async fn usage(&self) -> BoxliteResult<ImageUsage> {
self.manager.image_usage().await
}
pub async fn create_alias(
&self,
name: &str,
alias: &str,
digest: &str,
) -> BoxliteResult<ImageDetail> {
self.manager
.create_alias(
checked_name(name)?,
checked_alias(alias)?,
checked_digest(digest)?,
)
.await
}
pub async fn delete_alias(&self, name: &str, alias: &str) -> BoxliteResult<()> {
self.manager
.delete_alias(checked_name(name)?, checked_alias(alias)?)
.await
}
}
#[cfg(test)]
mod tests {
use super::{checked_alias, checked_digest, checked_name};
#[test]
fn accepts_a_tag_oci_accepts() {
for tag in ["v1", "stable", "_private", "3.20-alpine", &"a".repeat(128)] {
assert_eq!(checked_alias(tag).unwrap(), tag);
}
}
#[test]
fn refuses_a_tag_oci_refuses() {
for tag in [
"",
"-v1",
".hidden",
"v1/x",
"v 1",
"v1:x",
&"a".repeat(129),
] {
assert!(checked_alias(tag).is_err(), "{tag:?} was accepted");
}
}
#[test]
fn accepts_only_a_sha256_digest() {
let digest = format!("sha256:{}", "a".repeat(64));
assert_eq!(checked_digest(&digest).unwrap(), digest);
for digest in [
String::new(),
"latest".into(),
"sha256:aa".into(),
format!("sha256:{}", "A".repeat(64)),
format!("sha512:{}", "a".repeat(64)),
format!("sha256:{}", "a".repeat(65)),
] {
assert!(checked_digest(&digest).is_err(), "{digest:?} was accepted");
}
}
#[test]
fn accepts_an_image_name() {
for name in [
"alpine",
"docker.io/library/alpine",
"quay.io/acme/app",
"localhost:5000/app",
] {
assert_eq!(checked_name(name).unwrap(), name);
}
}
#[test]
fn refuses_a_reference_that_carries_a_tag_or_digest() {
for (reference, name) in [
("alpine:3.20", "alpine"),
("quay.io/acme/app:v1", "quay.io/acme/app"),
("localhost:5000/app:v1", "localhost:5000/app"),
(
"alpine@sha256:4a1c2f00000000000000000000000000000000000000000000000000000000aa",
"alpine",
),
] {
let message = checked_name(reference).unwrap_err().to_string();
assert!(
message.starts_with("invalid argument:"),
"{reference}: {message}"
);
assert!(message.contains(&format!("such as '{name}'")), "{message}");
}
}
#[test]
fn refuses_what_could_reach_another_route() {
let too_long = "a".repeat(super::MAX_NAME_CHARS + 1);
for name in [
"",
" acme/app",
"acme/app\n",
"acme\u{7f}app",
"/etc/passwd",
"../etc",
"quay.io/acme/..",
"quay.io\\..\\x",
".",
"acme/./app",
"usage",
"registries",
too_long.as_str(),
] {
let message = checked_name(name).unwrap_err().to_string();
assert!(
message.starts_with("invalid argument:"),
"{name:?}: {message}"
);
}
}
}