1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub use use_oci_digest::OciDigest as Digest;
8use use_oci_digest::OciDigest;
9pub use use_oci_distribution::{RegistryHost as Registry, RepositoryName as Repository};
10use use_oci_distribution::{RegistryHost, RepositoryName};
11pub use use_oci_tag::OciTag as TagName;
12use use_oci_tag::{OciTag, OciTagError};
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum ReferenceError {
17 Empty,
18 InvalidName,
19 InvalidTag,
20 InvalidDigest,
21}
22
23impl fmt::Display for ReferenceError {
24 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 Self::Empty => formatter.write_str("OCI reference cannot be empty"),
27 Self::InvalidName => formatter.write_str("invalid OCI image name"),
28 Self::InvalidTag => formatter.write_str("invalid OCI tag"),
29 Self::InvalidDigest => formatter.write_str("invalid OCI digest"),
30 }
31 }
32}
33
34impl Error for ReferenceError {}
35
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub struct ImageName {
39 registry: Option<RegistryHost>,
40 repository: RepositoryName,
41 value: String,
42}
43
44impl ImageName {
45 pub fn new(value: impl AsRef<str>) -> Result<Self, ReferenceError> {
47 let trimmed = value.as_ref().trim();
48 if trimmed.is_empty() {
49 return Err(ReferenceError::Empty);
50 }
51 if trimmed.contains('@') || has_tag_separator(trimmed) {
52 return Err(ReferenceError::InvalidName);
53 }
54 parse_name(trimmed)
55 }
56
57 #[must_use]
59 pub const fn registry(&self) -> Option<&RegistryHost> {
60 self.registry.as_ref()
61 }
62
63 #[must_use]
65 pub const fn repository(&self) -> &RepositoryName {
66 &self.repository
67 }
68
69 #[must_use]
71 pub fn as_str(&self) -> &str {
72 &self.value
73 }
74}
75
76impl AsRef<str> for ImageName {
77 fn as_ref(&self) -> &str {
78 self.as_str()
79 }
80}
81
82impl fmt::Display for ImageName {
83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84 formatter.write_str(self.as_str())
85 }
86}
87
88#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct TaggedReference {
91 name: ImageName,
92 tag: OciTag,
93}
94
95impl TaggedReference {
96 #[must_use]
98 pub fn new(name: ImageName, tag: OciTag) -> Self {
99 Self { name, tag }
100 }
101
102 #[must_use]
104 pub const fn tag(&self) -> &OciTag {
105 &self.tag
106 }
107}
108
109impl fmt::Display for TaggedReference {
110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111 write!(formatter, "{}:{}", self.name, self.tag)
112 }
113}
114
115#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117pub struct DigestedReference {
118 name: ImageName,
119 digest: OciDigest,
120}
121
122impl DigestedReference {
123 #[must_use]
125 pub fn new(name: ImageName, digest: OciDigest) -> Self {
126 Self { name, digest }
127 }
128
129 #[must_use]
131 pub const fn digest(&self) -> &OciDigest {
132 &self.digest
133 }
134}
135
136impl fmt::Display for DigestedReference {
137 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138 write!(formatter, "{}@{}", self.name, self.digest)
139 }
140}
141
142#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
144pub struct ImageReference {
145 name: ImageName,
146 tag: Option<OciTag>,
147 digest: Option<OciDigest>,
148 value: String,
149}
150
151impl ImageReference {
152 pub fn parse(value: impl AsRef<str>) -> Result<Self, ReferenceError> {
154 parse_reference(value.as_ref())
155 }
156
157 #[must_use]
159 pub const fn name(&self) -> &ImageName {
160 &self.name
161 }
162
163 #[must_use]
165 pub const fn registry(&self) -> Option<&RegistryHost> {
166 self.name.registry()
167 }
168
169 #[must_use]
171 pub const fn repository(&self) -> &RepositoryName {
172 self.name.repository()
173 }
174
175 #[must_use]
177 pub const fn tag(&self) -> Option<&OciTag> {
178 self.tag.as_ref()
179 }
180
181 #[must_use]
183 pub const fn digest(&self) -> Option<&OciDigest> {
184 self.digest.as_ref()
185 }
186
187 #[must_use]
189 pub fn as_str(&self) -> &str {
190 &self.value
191 }
192}
193
194impl fmt::Display for ImageReference {
195 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196 formatter.write_str(self.as_str())
197 }
198}
199
200impl FromStr for ImageReference {
201 type Err = ReferenceError;
202
203 fn from_str(value: &str) -> Result<Self, Self::Err> {
204 Self::parse(value)
205 }
206}
207
208impl TryFrom<&str> for ImageReference {
209 type Error = ReferenceError;
210
211 fn try_from(value: &str) -> Result<Self, Self::Error> {
212 Self::parse(value)
213 }
214}
215
216pub type CanonicalReference = DigestedReference;
218
219fn parse_reference(value: &str) -> Result<ImageReference, ReferenceError> {
220 let trimmed = value.trim();
221 if trimmed.is_empty() {
222 return Err(ReferenceError::Empty);
223 }
224 if trimmed.chars().any(char::is_whitespace) {
225 return Err(ReferenceError::InvalidName);
226 }
227 let (without_digest, digest) = match trimmed.split_once('@') {
228 Some((name, digest)) => (
229 name,
230 Some(
231 digest
232 .parse::<OciDigest>()
233 .map_err(|_| ReferenceError::InvalidDigest)?,
234 ),
235 ),
236 None => (trimmed, None),
237 };
238 let slash_index = without_digest.rfind('/');
239 let colon_index = without_digest.rfind(':');
240 let (name_part, tag) = match colon_index {
241 Some(index) if slash_index.is_none_or(|slash| index > slash) => {
242 let tag = OciTag::new(&without_digest[index + 1..]).map_err(map_tag_error)?;
243 (&without_digest[..index], Some(tag))
244 },
245 _ => (without_digest, None),
246 };
247 let name = parse_name(name_part)?;
248 let value = render_reference(name.as_str(), tag.as_ref(), digest.as_ref());
249 Ok(ImageReference {
250 name,
251 tag,
252 digest,
253 value,
254 })
255}
256
257fn parse_name(value: &str) -> Result<ImageName, ReferenceError> {
258 let (registry, repository_text) = split_registry(value);
259 let registry = registry
260 .map(RegistryHost::new)
261 .transpose()
262 .map_err(|_| ReferenceError::InvalidName)?;
263 let repository =
264 RepositoryName::new(repository_text).map_err(|_| ReferenceError::InvalidName)?;
265 let value = registry.as_ref().map_or_else(
266 || repository.to_string(),
267 |registry| format!("{registry}/{repository}"),
268 );
269 Ok(ImageName {
270 registry,
271 repository,
272 value,
273 })
274}
275
276fn split_registry(value: &str) -> (Option<&str>, &str) {
277 let Some((first, rest)) = value.split_once('/') else {
278 return (None, value);
279 };
280 if first.contains('.') || first.contains(':') || first == "localhost" {
281 (Some(first), rest)
282 } else {
283 (None, value)
284 }
285}
286
287fn has_tag_separator(value: &str) -> bool {
288 let slash_index = value.rfind('/');
289 value
290 .rfind(':')
291 .is_some_and(|colon| slash_index.is_none_or(|slash| colon > slash))
292}
293
294fn render_reference(name: &str, tag: Option<&OciTag>, digest: Option<&OciDigest>) -> String {
295 let mut value = name.to_string();
296 if let Some(tag) = tag {
297 value.push(':');
298 value.push_str(tag.as_str());
299 }
300 if let Some(digest) = digest {
301 value.push('@');
302 value.push_str(digest.as_str());
303 }
304 value
305}
306
307fn map_tag_error(_error: OciTagError) -> ReferenceError {
308 ReferenceError::InvalidTag
309}
310
311#[cfg(test)]
312mod tests {
313 use super::{ImageName, ImageReference, ReferenceError, TaggedReference};
314 use use_oci_tag::OciTag;
315
316 const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
317
318 #[test]
319 fn parses_tagged_and_digested_references() -> Result<(), Box<dyn std::error::Error>> {
320 let reference: ImageReference =
321 format!("ghcr.io/rustuse/app:0.1.0@sha256:{SHA}").parse()?;
322 let tagged = TaggedReference::new(ImageName::new("rustuse/app")?, OciTag::new("latest")?);
323
324 assert_eq!(
325 reference.registry().map(ToString::to_string),
326 Some("ghcr.io".to_string())
327 );
328 assert_eq!(reference.repository().as_str(), "rustuse/app");
329 assert_eq!(reference.tag().map(OciTag::as_str), Some("0.1.0"));
330 assert!(reference.digest().is_some());
331 assert_eq!(tagged.to_string(), "rustuse/app:latest");
332 assert_eq!(ImageName::new("bad:name"), Err(ReferenceError::InvalidName));
333 Ok(())
334 }
335}