use crate::{Name, Reference};
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::{
fmt,
path::{Path, PathBuf},
str::FromStr,
};
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ImageName {
pub hostname: String,
pub port: Option<u16>,
pub name: Name,
pub reference: Reference,
}
impl fmt::Display for ImageName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(port) = self.port {
write!(
f,
"{}:{}/{}:{}",
self.hostname, port, self.name, self.reference
)
} else {
write!(f, "{}/{}:{}", self.hostname, self.name, self.reference)
}
}
}
impl Default for ImageName {
fn default() -> Self {
Self::parse(&format!("{}", uuid::Uuid::new_v4().as_hyphenated()))
.expect("UUID hyphenated must be valid name")
}
}
impl FromStr for ImageName {
type Err = anyhow::Error;
fn from_str(name: &str) -> Result<Self> {
let (hostname, name) = name
.split_once('/')
.unwrap_or(("registry-1.docker.io", name));
let (hostname, port) = if let Some((hostname, port)) = hostname.split_once(':') {
(hostname, Some(str::parse(port)?))
} else {
(hostname, None)
};
let (name, reference) = name.split_once(':').unwrap_or((name, "latest"));
Ok(ImageName {
hostname: hostname.to_string(),
port,
name: Name::new(name)?,
reference: Reference::new(reference)?,
})
}
}
impl Serialize for ImageName {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for ImageName {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
ImageName::parse(&s).map_err(serde::de::Error::custom)
}
}
impl ImageName {
pub fn parse(name: &str) -> Result<Self> {
Self::from_str(name)
}
pub fn registry_url(&self) -> Result<Url> {
let hostname = if let Some(port) = self.port {
format!("{}:{}", self.hostname, port)
} else {
self.hostname.clone()
};
let url = if self.hostname.starts_with("localhost") {
format!("http://{}", hostname)
} else {
format!("https://{}", hostname)
};
Ok(Url::parse(&url)?)
}
pub fn as_escaped_path(&self) -> PathBuf {
PathBuf::from(if let Some(port) = &self.port {
format!(
"{}%3A{}/{}%3A{}",
self.hostname,
port,
self.name,
self.reference.encoded()
)
} else {
format!(
"{}/{}%3A{}",
self.hostname,
self.name.as_str(),
self.reference.encoded()
)
})
}
pub fn from_escaped_path(path: &Path) -> Result<Self> {
let image_name =
urlencoding::decode(path.as_os_str().to_str().context("Non UTF-8 file path")?)?;
Self::parse(&image_name)
}
pub fn as_path(&self) -> PathBuf {
let reference = self.reference.replace(':', "__");
PathBuf::from(if let Some(port) = self.port {
format!("{}__{}/{}/__{}", self.hostname, port, self.name, reference)
} else {
format!("{}/{}/__{}", self.hostname, self.name, reference)
})
}
pub fn from_path(path: &Path) -> Result<Self> {
let components = path
.components()
.map(|c| {
c.as_os_str()
.to_str()
.context("Try to convert a path including non UTF-8 character")
})
.collect::<Result<Vec<&str>>>()?;
let n = components.len();
if n < 3 {
bail!(
"Path for image name must consist of registry, name, and tag: {}",
path.display()
);
}
let registry = &components[0];
let mut iter = registry.split("__");
let hostname = iter
.next()
.with_context(|| anyhow!("Invalid registry: {registry}"))?
.to_string();
let port = iter
.next()
.map(|port| str::parse(port).context("Invalid port number"))
.transpose()?;
let name = Name::new(&components[1..n - 1].join("/"))?;
let reference = Reference::new(
&components[n - 1]
.strip_prefix("__")
.with_context(|| anyhow!("Missing tag in path: {}", path.display()))?
.replace("__", ":"),
)?;
Ok(ImageName {
hostname,
port,
name,
reference,
})
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn as_path() -> Result<()> {
fn test(name: &str, path: &Path) -> Result<()> {
let image_name = ImageName::parse(name)?;
assert_eq!(image_name.as_path(), path);
assert_eq!(ImageName::from_path(&image_name.as_path())?, image_name);
Ok(())
}
test(
"localhost:5000/test_repo:latest",
"localhost__5000/test_repo/__latest".as_ref(),
)?;
test(
"ubuntu:20.04",
"registry-1.docker.io/ubuntu/__20.04".as_ref(),
)?;
test("alpine", "registry-1.docker.io/alpine/__latest".as_ref())?;
test(
"quay.io/jitesoft/alpine:sha256:6755355f801f8e3694bffb1a925786813462cea16f1ce2b0290b6a48acf2500c",
"quay.io/jitesoft/alpine/__sha256__6755355f801f8e3694bffb1a925786813462cea16f1ce2b0290b6a48acf2500c".as_ref()
)?;
Ok(())
}
#[test]
fn escaped_path() -> Result<()> {
fn test(name: &str, path: &Path) -> Result<()> {
let image_name = ImageName::parse(name)?;
let escaped = image_name.as_escaped_path();
assert_eq!(escaped, path);
assert_eq!(ImageName::from_escaped_path(&escaped)?, image_name);
Ok(())
}
test(
"localhost:5000/test_repo:latest",
"localhost%3A5000/test_repo%3Alatest".as_ref(),
)?;
test(
"ubuntu:20.04",
"registry-1.docker.io/ubuntu%3A20.04".as_ref(),
)?;
test("alpine", "registry-1.docker.io/alpine%3Alatest".as_ref())?;
test(
"quay.io/jitesoft/alpine:sha256:6755355f801f8e3694bffb1a925786813462cea16f1ce2b0290b6a48acf2500c",
"quay.io/jitesoft/alpine%3Asha256%3A6755355f801f8e3694bffb1a925786813462cea16f1ce2b0290b6a48acf2500c".as_ref()
)?;
Ok(())
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct SerializeTest {
name: ImageName,
}
#[test]
fn serialize() {
let input = SerializeTest {
name: ImageName::parse("localhost:5000/test_repo:latest").unwrap(),
};
let json = serde_json::to_string(&input).unwrap();
assert_eq!(json, r#"{"name":"localhost:5000/test_repo:latest"}"#);
let output: SerializeTest = serde_json::from_str(&json).unwrap();
assert_eq!(input, output)
}
}