Skip to main content

use_oci_reference/
lib.rs

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/// Errors returned when image references are invalid.
15#[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/// A repository name with an optional registry.
37#[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    /// Creates an image name from reference text without tag or digest components.
46    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    /// Returns the optional registry.
58    #[must_use]
59    pub const fn registry(&self) -> Option<&RegistryHost> {
60        self.registry.as_ref()
61    }
62
63    /// Returns the repository.
64    #[must_use]
65    pub const fn repository(&self) -> &RepositoryName {
66        &self.repository
67    }
68
69    /// Returns the name text.
70    #[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/// A name plus tag reference.
89#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct TaggedReference {
91    name: ImageName,
92    tag: OciTag,
93}
94
95impl TaggedReference {
96    /// Creates a tagged reference.
97    #[must_use]
98    pub fn new(name: ImageName, tag: OciTag) -> Self {
99        Self { name, tag }
100    }
101
102    /// Returns the tag.
103    #[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/// A name plus digest reference.
116#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117pub struct DigestedReference {
118    name: ImageName,
119    digest: OciDigest,
120}
121
122impl DigestedReference {
123    /// Creates a digested reference.
124    #[must_use]
125    pub fn new(name: ImageName, digest: OciDigest) -> Self {
126        Self { name, digest }
127    }
128
129    /// Returns the digest.
130    #[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/// A parsed OCI image reference with optional tag and digest.
143#[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    /// Parses image reference text.
153    pub fn parse(value: impl AsRef<str>) -> Result<Self, ReferenceError> {
154        parse_reference(value.as_ref())
155    }
156
157    /// Returns the image name.
158    #[must_use]
159    pub const fn name(&self) -> &ImageName {
160        &self.name
161    }
162
163    /// Returns the optional registry.
164    #[must_use]
165    pub const fn registry(&self) -> Option<&RegistryHost> {
166        self.name.registry()
167    }
168
169    /// Returns the repository.
170    #[must_use]
171    pub const fn repository(&self) -> &RepositoryName {
172        self.name.repository()
173    }
174
175    /// Returns the optional tag.
176    #[must_use]
177    pub const fn tag(&self) -> Option<&OciTag> {
178        self.tag.as_ref()
179    }
180
181    /// Returns the optional digest.
182    #[must_use]
183    pub const fn digest(&self) -> Option<&OciDigest> {
184        self.digest.as_ref()
185    }
186
187    /// Returns the normalized reference text.
188    #[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
216/// A reference that includes a digest.
217pub 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}