greentic_types/
component_source.rs1use alloc::string::{String, ToString};
4
5#[cfg(feature = "schemars")]
6use schemars::JsonSchema;
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
13#[cfg_attr(feature = "schemars", derive(JsonSchema))]
14#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
15pub enum ComponentSourceRef {
16 Oci(String),
18 Repo(String),
20 Store(String),
22 File(String),
24}
25
26impl ComponentSourceRef {
27 pub fn scheme(&self) -> &'static str {
29 match self {
30 ComponentSourceRef::Oci(_) => "oci",
31 ComponentSourceRef::Repo(_) => "repo",
32 ComponentSourceRef::Store(_) => "store",
33 ComponentSourceRef::File(_) => "file",
34 }
35 }
36
37 pub fn reference(&self) -> &str {
39 match self {
40 ComponentSourceRef::Oci(value) => value,
41 ComponentSourceRef::Repo(value) => value,
42 ComponentSourceRef::Store(value) => value,
43 ComponentSourceRef::File(value) => value,
44 }
45 }
46
47 pub fn is_tag(&self) -> bool {
49 matches!(self.oci_reference_kind(), Some(OciReferenceKind::Tag))
50 }
51
52 pub fn is_digest(&self) -> bool {
54 matches!(self.oci_reference_kind(), Some(OciReferenceKind::Digest))
55 }
56
57 pub fn normalized(&self) -> String {
59 match self {
60 ComponentSourceRef::Oci(reference) => normalize_oci_reference(reference),
61 _ => self.to_string(),
62 }
63 }
64}
65
66impl core::fmt::Display for ComponentSourceRef {
67 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
68 write!(f, "{}://{}", self.scheme(), self.reference())
69 }
70}
71
72impl core::str::FromStr for ComponentSourceRef {
73 type Err = ComponentSourceRefError;
74
75 fn from_str(value: &str) -> Result<Self, Self::Err> {
76 if value.is_empty() {
77 return Err(ComponentSourceRefError::EmptyReference);
78 }
79 if value.chars().any(char::is_whitespace) {
80 return Err(ComponentSourceRefError::ContainsWhitespace);
81 }
82 if value.starts_with("oci://") {
83 return parse_with_scheme(value, "oci://").map(ComponentSourceRef::Oci);
84 }
85 if value.starts_with("repo://") {
86 return parse_with_scheme(value, "repo://").map(ComponentSourceRef::Repo);
87 }
88 if value.starts_with("store://") {
89 return parse_with_scheme(value, "store://").map(ComponentSourceRef::Store);
90 }
91 if value.starts_with("file://") {
92 return parse_with_scheme(value, "file://").map(ComponentSourceRef::File);
93 }
94 Err(ComponentSourceRefError::InvalidScheme)
95 }
96}
97
98impl From<ComponentSourceRef> for String {
99 fn from(value: ComponentSourceRef) -> Self {
100 value.to_string()
101 }
102}
103
104impl TryFrom<String> for ComponentSourceRef {
105 type Error = ComponentSourceRefError;
106
107 fn try_from(value: String) -> Result<Self, Self::Error> {
108 value.parse()
109 }
110}
111
112#[derive(Debug, thiserror::Error, PartialEq, Eq)]
114pub enum ComponentSourceRefError {
115 #[error("component source reference cannot be empty")]
117 EmptyReference,
118 #[error("component source reference must not contain whitespace")]
120 ContainsWhitespace,
121 #[error("component source reference must use oci://, repo://, store://, or file://")]
123 InvalidScheme,
124 #[error("component source reference is missing a locator")]
126 MissingLocator,
127}
128
129fn parse_with_scheme(value: &str, scheme: &str) -> Result<String, ComponentSourceRefError> {
130 if let Some(rest) = value.strip_prefix(scheme) {
131 if rest.is_empty() {
132 return Err(ComponentSourceRefError::MissingLocator);
133 }
134 return Ok(rest.to_string());
135 }
136 Err(ComponentSourceRefError::InvalidScheme)
137}
138
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140enum OciReferenceKind {
141 Tag,
142 Digest,
143}
144
145struct OciReferenceParts<'a> {
146 name: &'a str,
147 tag: Option<&'a str>,
148 digest: Option<&'a str>,
149}
150
151impl ComponentSourceRef {
152 fn oci_reference_kind(&self) -> Option<OciReferenceKind> {
153 let ComponentSourceRef::Oci(reference) = self else {
154 return None;
155 };
156 let parts = split_oci_reference(reference);
157 if parts.digest.is_some() {
158 Some(OciReferenceKind::Digest)
159 } else if parts.tag.is_some() {
160 Some(OciReferenceKind::Tag)
161 } else {
162 None
163 }
164 }
165}
166
167fn split_oci_reference(reference: &str) -> OciReferenceParts<'_> {
168 let (name_with_tag, digest) = match reference.split_once('@') {
169 Some((name, digest)) => (name, Some(digest)),
170 None => (reference, None),
171 };
172 let (name, tag) = split_oci_tag(name_with_tag);
173 OciReferenceParts { name, tag, digest }
174}
175
176fn split_oci_tag(reference: &str) -> (&str, Option<&str>) {
177 let last_slash = reference.rfind('/');
178 let last_colon = reference.rfind(':');
179 if let Some(colon) = last_colon {
180 if last_slash.is_none_or(|slash| colon > slash) {
181 let tag = &reference[colon + 1..];
182 if !tag.is_empty() {
183 return (&reference[..colon], Some(tag));
184 }
185 }
186 }
187 (reference, None)
188}
189
190fn normalize_oci_reference(reference: &str) -> String {
191 let parts = split_oci_reference(reference);
192 if let Some(digest) = parts.digest {
193 format!("oci://{}@{}", parts.name, digest)
194 } else if let Some(tag) = parts.tag {
195 format!("oci://{}:{}", parts.name, tag)
196 } else {
197 format!("oci://{}", reference)
198 }
199}