use base64::Engine;
use bon::Builder;
use jiff::Timestamp;
use serde::de::Error;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use snafu::{OptionExt, ResultExt};
use std::env::consts;
use std::str::FromStr;
use std::{collections::HashMap, fmt};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MediaType {
ImageIndex,
Manifest,
Config,
Layer(Compression),
DockerManifestList,
DockerManifest,
DockerContainerImage,
DockerImageRootfs(Compression),
}
impl MediaType {
pub fn compression(&self) -> Compression {
match self {
Self::DockerImageRootfs(compression) => {
if *compression == Compression::None {
Compression::Gzip
} else {
compression.clone()
}
}
Self::Layer(compression) => compression.clone(),
_ => Compression::None,
}
}
}
impl Serialize for MediaType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let string = match self {
Self::ImageIndex => "application/vnd.oci.image.index.v1+json".into(),
Self::Manifest => "application/vnd.oci.image.manifest.v1+json".into(),
Self::Config => "application/vnd.oci.image.config.v1+json".into(),
Self::Layer(compression) => format!(
"application/vnd.oci.image.layer.v1.tar{}",
compression.to_ext()
),
Self::DockerManifestList => {
"application/vnd.docker.distribution.manifest.list.v2+json".into()
}
Self::DockerManifest => "application/vnd.docker.distribution.manifest.v2+json".into(),
Self::DockerContainerImage => "application/vnd.docker.container.image.v1+json".into(),
Self::DockerImageRootfs(compression) => format!(
"application/vnd.docker.image.rootfs.diff.tar{}",
compression.to_ext()
),
};
serializer.serialize_str(string.as_str())
}
}
impl<'de> Deserialize<'de> for MediaType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let string = String::deserialize(deserializer)?;
if string.starts_with("application/vnd.docker.image.rootfs.diff.tar") {
let compression = Compression::new(string.as_str());
Ok(MediaType::DockerImageRootfs(compression))
} else if string.starts_with("application/vnd.oci.image.layer.v1.tar") {
let compression = Compression::new(string.as_str());
Ok(MediaType::Layer(compression))
} else {
match string.as_ref() {
"application/vnd.docker.distribution.manifest.list.v2+json" => {
Ok(MediaType::DockerManifestList)
}
"application/vnd.docker.distribution.manifest.v2+json" => {
Ok(MediaType::DockerManifest)
}
"application/vnd.docker.container.image.v1+json" => {
Ok(MediaType::DockerContainerImage)
}
"application/vnd.oci.image.manifest.v1+json" => Ok(MediaType::Manifest),
"application/vnd.oci.image.index.v1+json" => Ok(MediaType::ImageIndex),
"application/vnd.oci.image.config.v1+json" => Ok(MediaType::Config),
variant => Err(D::Error::unknown_variant(
variant,
&[
"application/vnd.docker.image.rootfs.diff.tar.*",
"application/vnd.docker.container.image.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.config.v1+json",
],
)),
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Compression {
Gzip,
Bzip2,
Lz4,
Xz,
Zstd,
None,
}
impl Compression {
pub fn new(string: &str) -> Self {
if string.ends_with(".gz") || string.ends_with(".gzip2") {
Compression::Gzip
} else if string.ends_with(".xz") {
Compression::Xz
} else if string.ends_with(".lz4") {
Compression::Lz4
} else if string.ends_with(".zst") {
Compression::Zstd
} else if string.ends_with(".bz2") || string.ends_with(".bzip2") {
Compression::Bzip2
} else {
Compression::None
}
}
pub fn to_ext(&self) -> &str {
match self {
Self::Gzip => ".gz",
Self::Bzip2 => ".bz2",
Self::Lz4 => ".lz4",
Self::Xz => ".xz",
Self::Zstd => ".zst",
Self::None => "",
}
}
}
#[derive(Builder, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct TarballManifest {
#[builder(into)]
pub config: String,
#[builder(into)]
pub repo_tags: Vec<String>,
#[builder(into)]
pub layers: Vec<String>,
}
#[derive(Builder, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Platform {
#[builder(into)]
pub architecture: String,
#[builder(into)]
pub os: String,
}
impl Default for Platform {
fn default() -> Self {
let arch = match consts::ARCH {
"arm" | "aarch64" | "longaarch64" => "arm64",
_ => "amd64",
};
Self {
os: "linux".to_string(),
architecture: arch.to_string(),
}
}
}
impl FromStr for Platform {
type Err = crate::error::Error;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
let (os, architecture) = value.split_once('/').context(
crate::error::InvalidPlatformFormatSnafu {
value: value.to_string(),
},
)?;
snafu::ensure!(
!os.is_empty() && !architecture.is_empty(),
crate::error::InvalidPlatformEmptySnafu {
value: value.to_string(),
}
);
Ok(Self {
architecture: architecture.to_string(),
os: os.to_string(),
})
}
}
impl TryFrom<String> for Platform {
type Error = crate::error::Error;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
value.parse()
}
}
impl fmt::Display for Platform {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_fmt(format_args!("{}/{}", self.os, self.architecture))
}
}
#[derive(Builder, Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "PascalCase")]
pub struct Config {
#[builder(into)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[builder(into)]
#[serde(default)]
pub env: Vec<String>,
#[builder(into)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cmd: Vec<String>,
#[builder(into)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[builder(into)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_build: Option<String>,
#[builder(into)]
#[serde(default)]
pub args_escaped: bool,
#[builder(into)]
#[serde(default)]
pub labels: HashMap<String, String>,
}
#[derive(Builder, Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
pub struct History {
#[builder(into)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Timestamp>,
#[builder(into)]
pub created_by: String,
#[builder(into)]
pub comment: String,
#[builder(into)]
#[serde(default)]
pub empty_layer: bool,
}
#[derive(Builder, Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ImageConfig {
#[builder(into)]
pub architecture: String,
#[builder(into)]
pub config: Config,
#[builder(into)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Timestamp>,
#[builder(into)]
pub history: Vec<History>,
#[builder(into)]
pub os: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TagList {
pub name: String,
pub tags: Vec<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RepositoryList {
pub repositories: Vec<String>,
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
BlobUnknown,
BlobUploadInvalid,
BlobUploadUnknown,
DigestInvalid,
ManifestBlobUnknown,
ManifestInvalid,
ManifestUnknown,
NameInvalid,
NameUnknown,
SizeInvalid,
Unauthorized,
Denied,
Unsupported,
#[serde(rename = "TOOMANYREQUESTS")]
TooManyRequests,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ErrorInfo {
pub code: ErrorCode,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub detail: Option<String>,
}
impl fmt::Display for ErrorInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = if let Some(message) = self.message.as_ref() {
if let Some(detail) = self.detail.as_ref() {
format!("{message}: {detail}")
} else {
message.clone()
}
} else if let Some(detail) = self.detail.as_ref() {
detail.clone()
} else {
"unknown error occured".to_string()
};
let code = match self.code {
ErrorCode::BlobUnknown => "blob unknown",
ErrorCode::BlobUploadInvalid => "blob upload invalid",
ErrorCode::BlobUploadUnknown => "blob upload unknown",
ErrorCode::Denied => "denied",
ErrorCode::DigestInvalid => "digest invalid",
ErrorCode::ManifestBlobUnknown => "manifest blob unknown",
ErrorCode::ManifestInvalid => "manifest invalid",
ErrorCode::ManifestUnknown => "manifest unknown",
ErrorCode::NameInvalid => "name invalid",
ErrorCode::NameUnknown => "name unknown",
ErrorCode::SizeInvalid => "size invalid",
ErrorCode::TooManyRequests => "too many requests",
ErrorCode::Unauthorized => "unauthorized",
ErrorCode::Unsupported => "unsupported",
};
f.write_fmt(format_args!("[{code}] {message}"))
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ErrorResponse {
pub errors: Vec<ErrorInfo>,
}
impl fmt::Display for ErrorResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_fmt(format_args!(
"{}",
self.errors
.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join("\n")
))
}
}
#[derive(Debug, Clone)]
pub enum Token {
Bearer(String),
Basic { username: String, password: String },
}
impl Token {
pub fn parse(value: DockerAuth) -> Result<Option<Self>, crate::error::Error> {
if let Some(identitytoken) = value.identitytoken {
return Ok(Some(Self::Bearer(identitytoken)));
}
let Some(auth) = value.auth else {
return Ok(None);
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(auth)
.context(crate::error::AuthBase64DecodeSnafu {
context: "docker auth",
})?;
let decoded =
std::str::from_utf8(&decoded).context(crate::error::AuthUtf8Snafu {
context: "docker auth",
})?;
let (username, password) =
decoded
.split_once(':')
.context(crate::error::AuthMissingSeparatorSnafu {
context: "docker auth",
})?;
Ok(Some(Self::Basic {
username: username.to_string(),
password: password.to_string(),
}))
}
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct DockerConfig {
#[serde(default)]
pub auths: HashMap<String, DockerAuth>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct DockerAuth {
pub auth: Option<String>,
pub identitytoken: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_parses_valid() {
let p: Platform = "linux/amd64".parse().expect("should parse");
assert_eq!(p.os, "linux");
assert_eq!(p.architecture, "amd64");
}
#[test]
fn platform_rejects_missing_separator() {
let err = "linux".parse::<Platform>().expect_err("should fail");
assert!(matches!(
err,
crate::error::Error::InvalidPlatformFormat { .. }
));
}
#[test]
fn platform_rejects_empty_components() {
assert!(matches!(
"/amd64".parse::<Platform>(),
Err(crate::error::Error::InvalidPlatformEmpty { .. })
));
assert!(matches!(
"linux/".parse::<Platform>(),
Err(crate::error::Error::InvalidPlatformEmpty { .. })
));
}
#[test]
fn platform_try_from_string_round_trips_display() {
let p = Platform::try_from("linux/arm64".to_string()).unwrap();
assert_eq!(p.to_string(), "linux/arm64");
}
}