use alloc::string::{String, ToString};
#[cfg(feature = "schemars")]
use schemars::JsonSchema;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub enum ComponentSourceRef {
Oci(String),
Repo(String),
Store(String),
File(String),
}
impl ComponentSourceRef {
pub fn scheme(&self) -> &'static str {
match self {
ComponentSourceRef::Oci(_) => "oci",
ComponentSourceRef::Repo(_) => "repo",
ComponentSourceRef::Store(_) => "store",
ComponentSourceRef::File(_) => "file",
}
}
pub fn reference(&self) -> &str {
match self {
ComponentSourceRef::Oci(value) => value,
ComponentSourceRef::Repo(value) => value,
ComponentSourceRef::Store(value) => value,
ComponentSourceRef::File(value) => value,
}
}
pub fn is_tag(&self) -> bool {
matches!(self.oci_reference_kind(), Some(OciReferenceKind::Tag))
}
pub fn is_digest(&self) -> bool {
matches!(self.oci_reference_kind(), Some(OciReferenceKind::Digest))
}
pub fn normalized(&self) -> String {
match self {
ComponentSourceRef::Oci(reference) => normalize_oci_reference(reference),
_ => self.to_string(),
}
}
}
impl core::fmt::Display for ComponentSourceRef {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}://{}", self.scheme(), self.reference())
}
}
impl core::str::FromStr for ComponentSourceRef {
type Err = ComponentSourceRefError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() {
return Err(ComponentSourceRefError::EmptyReference);
}
if value.chars().any(char::is_whitespace) {
return Err(ComponentSourceRefError::ContainsWhitespace);
}
if value.starts_with("oci://") {
return parse_with_scheme(value, "oci://").map(ComponentSourceRef::Oci);
}
if value.starts_with("repo://") {
return parse_with_scheme(value, "repo://").map(ComponentSourceRef::Repo);
}
if value.starts_with("store://") {
return parse_with_scheme(value, "store://").map(ComponentSourceRef::Store);
}
if value.starts_with("file://") {
return parse_with_scheme(value, "file://").map(ComponentSourceRef::File);
}
Err(ComponentSourceRefError::InvalidScheme)
}
}
impl From<ComponentSourceRef> for String {
fn from(value: ComponentSourceRef) -> Self {
value.to_string()
}
}
impl TryFrom<String> for ComponentSourceRef {
type Error = ComponentSourceRefError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ComponentSourceRefError {
#[error("component source reference cannot be empty")]
EmptyReference,
#[error("component source reference must not contain whitespace")]
ContainsWhitespace,
#[error("component source reference must use oci://, repo://, store://, or file://")]
InvalidScheme,
#[error("component source reference is missing a locator")]
MissingLocator,
}
fn parse_with_scheme(value: &str, scheme: &str) -> Result<String, ComponentSourceRefError> {
if let Some(rest) = value.strip_prefix(scheme) {
if rest.is_empty() {
return Err(ComponentSourceRefError::MissingLocator);
}
return Ok(rest.to_string());
}
Err(ComponentSourceRefError::InvalidScheme)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OciReferenceKind {
Tag,
Digest,
}
struct OciReferenceParts<'a> {
name: &'a str,
tag: Option<&'a str>,
digest: Option<&'a str>,
}
impl ComponentSourceRef {
fn oci_reference_kind(&self) -> Option<OciReferenceKind> {
let ComponentSourceRef::Oci(reference) = self else {
return None;
};
let parts = split_oci_reference(reference);
if parts.digest.is_some() {
Some(OciReferenceKind::Digest)
} else if parts.tag.is_some() {
Some(OciReferenceKind::Tag)
} else {
None
}
}
}
fn split_oci_reference(reference: &str) -> OciReferenceParts<'_> {
let (name_with_tag, digest) = match reference.split_once('@') {
Some((name, digest)) => (name, Some(digest)),
None => (reference, None),
};
let (name, tag) = split_oci_tag(name_with_tag);
OciReferenceParts { name, tag, digest }
}
fn split_oci_tag(reference: &str) -> (&str, Option<&str>) {
let last_slash = reference.rfind('/');
let last_colon = reference.rfind(':');
if let Some(colon) = last_colon
&& last_slash.is_none_or(|slash| colon > slash)
{
let tag = &reference[colon + 1..];
if !tag.is_empty() {
return (&reference[..colon], Some(tag));
}
}
(reference, None)
}
fn normalize_oci_reference(reference: &str) -> String {
let parts = split_oci_reference(reference);
if let Some(digest) = parts.digest {
format!("oci://{}@{}", parts.name, digest)
} else if let Some(tag) = parts.tag {
format!("oci://{}:{}", parts.name, tag)
} else {
format!("oci://{}", reference)
}
}