#![cfg_attr(not(feature = "serde-deserialize"), no_std)]
#![forbid(unsafe_code)]
extern crate alloc;
use alloc::string::{String, ToString};
use core::fmt;
use core::str::FromStr;
use lazy_static::lazy_static;
use regex::Regex;
#[derive(Debug, PartialEq)]
pub struct DockerImage {
pub registry: Option<String>,
pub name: String,
pub tag: Option<String>,
pub digest: Option<String>,
}
impl fmt::Display for DockerImage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(registry) = &self.registry {
write!(f, "{}/", registry)?;
}
write!(f, "{}", self.name)?;
if let Some(tag) = &self.tag {
write!(f, ":{}", tag)?;
}
if let Some(digest) = &self.digest {
write!(f, "@{}", digest)?;
}
Ok(())
}
}
#[derive(Debug, PartialEq)]
pub enum DockerImageError {
InvalidFormat,
}
impl fmt::Display for DockerImageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DockerImageError::InvalidFormat => write!(f, "Invalid Docker image format"),
}
}
}
impl core::error::Error for DockerImageError {}
impl FromStr for DockerImage {
type Err = DockerImageError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
lazy_static! {
static ref DOCKER_IMAGE_REGEX: Regex = Regex::new(
r"^(?:(?P<registry>[a-z0-9]+(?:[._-][a-z0-9]+)*\.[a-z]{2,}(?::\d+)?)/)?(?P<name>[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*)(?::(?P<tag>[a-zA-Z0-9._-]+))?(?:@(?P<digest>[a-z0-9]+:[a-fA-F0-9]{64}))?$"
)
.expect("Invalid regular expression for Docker image format");
}
if let Some(captures) = DOCKER_IMAGE_REGEX.captures(s) {
Ok(DockerImage {
registry: captures.name("registry").map(|m| m.as_str().to_string()),
name: captures
.name("name")
.ok_or(DockerImageError::InvalidFormat)?
.as_str()
.to_string(),
tag: captures.name("tag").map(|m| m.as_str().to_string()),
digest: captures.name("digest").map(|m| m.as_str().to_string()),
})
} else {
Err(DockerImageError::InvalidFormat)
}
}
}
impl TryFrom<String> for DockerImage {
type Error = DockerImageError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
impl TryFrom<&str> for DockerImage {
type Error = DockerImageError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
impl DockerImage {
pub fn parse(image_str: &str) -> Result<Self, DockerImageError> {
Self::from_str(image_str)
}
}
#[cfg(feature = "serde-serialize")]
impl serde::Serialize for DockerImage {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
#[cfg(feature = "serde-deserialize")]
impl<'de> serde::Deserialize<'de> for DockerImage {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let docker_image_str = <String as serde::Deserialize>::deserialize(deserializer)?;
docker_image_str.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_format::assert_display_fmt;
#[test]
fn test_trivial_name() {
let result = DockerImage::parse("nginx");
assert_eq!(
result,
Ok(DockerImage {
registry: None,
name: "nginx".to_string(),
tag: None,
digest: None,
})
);
}
#[test]
fn test_name_with_tag() {
let result = DockerImage::parse("nginx:latest");
assert_eq!(
result,
Ok(DockerImage {
registry: None,
name: "nginx".to_string(),
tag: Some("latest".to_string()),
digest: None,
})
);
}
#[test]
fn test_name_with_complex_tag() {
let result = DockerImage::parse("nginx:stable-alpine3.20-perl");
assert_eq!(
result,
Ok(DockerImage {
registry: None,
name: "nginx".to_string(),
tag: Some("stable-alpine3.20-perl".to_string()),
digest: None,
})
);
}
#[test]
fn test_registry_and_name() {
let result = DockerImage::parse("docker.io/nginx");
assert_eq!(
result,
Ok(DockerImage {
registry: Some("docker.io".to_string()),
name: "nginx".to_string(),
tag: None,
digest: None,
})
);
}
#[test]
fn test_registry_with_namespace() {
let result = DockerImage::parse("ghcr.io/nginx/nginx");
assert_eq!(
result,
Ok(DockerImage {
registry: Some("ghcr.io".to_string()),
name: "nginx/nginx".to_string(),
tag: None,
digest: None,
})
);
}
#[test]
fn test_name_with_digest() {
let result = DockerImage::parse(
"ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
);
assert_eq!(
result,
Ok(DockerImage {
registry: None,
name: "ubuntu".to_string(),
tag: None,
digest: Some(
"sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
.to_string()
),
})
);
}
#[test]
fn test_name_with_tag_and_digest() {
let result = DockerImage::parse(
"ubuntu:latest@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
);
assert_eq!(
result,
Ok(DockerImage {
registry: None,
name: "ubuntu".to_string(),
tag: Some("latest".to_string()),
digest: Some(
"sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
.to_string()
),
})
);
}
#[test]
fn test_registry_name_tag() {
let result = DockerImage::parse("registry.example.com/library/my-image:1.0.0");
assert_eq!(
result,
Ok(DockerImage {
registry: Some("registry.example.com".to_string()),
name: "library/my-image".to_string(),
tag: Some("1.0.0".to_string()),
digest: None,
})
);
}
#[test]
fn test_registry_name_digest() {
let result = DockerImage::parse(
"my-registry.local:5000/library/image-name@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
);
assert_eq!(
result,
Ok(DockerImage {
registry: Some("my-registry.local:5000".to_string()),
name: "library/image-name".to_string(),
tag: None,
digest: Some(
"sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
.to_string()
),
})
);
}
#[test]
fn test_invalid_format() {
let result = DockerImage::parse("invalid@@sha256:wrong");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_invalid_characters_in_tag() {
let result = DockerImage::parse("nginx:lat@est");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_invalid_digest_format() {
let result = DockerImage::parse("ubuntu@sha256:not-a-hex-string");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_invalid_registry_format() {
let result = DockerImage::parse("http://registry.example.com/image-name");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_invalid_double_colons_in_tag() {
let result = DockerImage::parse("nginx::latest");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_missing_image_name_with_tag() {
let result = DockerImage::parse(":latest");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_missing_image_name_with_digest() {
let result = DockerImage::parse(
"@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
);
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_extra_tag_components() {
let result = DockerImage::parse("my-image:1.0.0:latest");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_unicode_in_name() {
let result = DockerImage::parse("nginx🚀");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_unicode_in_registry() {
let result = DockerImage::parse("docker🚀.io/library/nginx");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_unicode_in_tag() {
let result = DockerImage::parse("nginx:lat🚀est");
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_unicode_in_digest() {
let result = DockerImage::parse(
"nginx@sha256:deadbeef🚀1234567890abcdef1234567890abcdef1234567890abcdef1234",
);
assert_eq!(result, Err(DockerImageError::InvalidFormat));
}
#[test]
fn test_display_trivial_name() {
let image = DockerImage {
registry: None,
name: "nginx".to_string(),
tag: None,
digest: None,
};
assert_display_fmt!(image, "nginx");
}
#[test]
fn test_display_name_with_tag() {
let image = DockerImage {
registry: None,
name: "nginx".to_string(),
tag: Some("latest".to_string()),
digest: None,
};
assert_display_fmt!(image, "nginx:latest");
}
#[test]
fn test_display_name_with_digest() {
let image = DockerImage {
registry: None,
name: "ubuntu".to_string(),
tag: None,
digest: Some(
"sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
),
};
assert_display_fmt!(
image,
"ubuntu@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
);
}
#[test]
fn test_display_name_with_tag_and_digest() {
let image = DockerImage {
registry: None,
name: "ubuntu".to_string(),
tag: Some("latest".to_string()),
digest: Some(
"sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
),
};
assert_display_fmt!(
image,
"ubuntu:latest@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
);
}
#[test]
fn test_display_registry_and_name() {
let image = DockerImage {
registry: Some("docker.io".to_string()),
name: "library/nginx".to_string(),
tag: None,
digest: None,
};
assert_display_fmt!(image, "docker.io/library/nginx");
}
#[test]
fn test_display_registry_name_with_tag() {
let image = DockerImage {
registry: Some("docker.io".to_string()),
name: "library/nginx".to_string(),
tag: Some("latest".to_string()),
digest: None,
};
assert_display_fmt!(image, "docker.io/library/nginx:latest");
}
#[test]
fn test_display_full_reference() {
let image = DockerImage {
registry: Some("my-registry.local:5000".to_string()),
name: "library/image-name".to_string(),
tag: Some("v1.0.0".to_string()),
digest: Some(
"sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
),
};
assert_display_fmt!(
image,
"my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
);
}
#[test]
#[cfg(feature = "serde-serialize")]
fn test_serialize_dockerimage_to_json() {
use serde_json;
let image = DockerImage {
registry: Some("my-registry.local:5000".to_string()),
name: "library/image-name".to_string(),
tag: Some("v1.0.0".to_string()),
digest: Some(
"sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
.to_string(),
),
};
let serialized = serde_json::to_string(&image).expect("Failed to serialize DockerImage");
assert_eq!(
serialized,
r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#
);
}
#[test]
#[cfg(feature = "serde-deserialize")]
fn test_deserialize_dockerimage_from_json() {
use serde_json;
let json = r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#;
let image: DockerImage =
serde_json::from_str(json).expect("Failed to deserialize DockerImage");
assert_eq!(
image,
DockerImage {
registry: Some("my-registry.local:5000".to_string()),
name: "library/image-name".to_string(),
tag: Some("v1.0.0".to_string()),
digest: Some(
"sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
.to_string()
),
}
);
}
}