greentic_types/
component_source.rs

1//! Canonical component source references for packs.
2
3use alloc::string::{String, ToString};
4
5#[cfg(feature = "schemars")]
6use schemars::JsonSchema;
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10/// Supported component source references.
11#[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    /// Public OCI reference (`oci://repo/name:tag` or `oci://repo/name@sha256:...`).
17    Oci(String),
18    /// Private repository reference (`repo://...`).
19    Repo(String),
20    /// Store-licensed component reference (`store://...`).
21    Store(String),
22    /// File-based component reference (`file://...`).
23    File(String),
24}
25
26impl ComponentSourceRef {
27    /// Returns the scheme name for this reference.
28    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    /// Returns the raw reference portion without the scheme prefix.
38    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    /// Returns `true` when this is an OCI reference using a tag suffix (`:tag`).
48    pub fn is_tag(&self) -> bool {
49        matches!(self.oci_reference_kind(), Some(OciReferenceKind::Tag))
50    }
51
52    /// Returns `true` when this is an OCI reference using a digest suffix (`@sha256:...`).
53    pub fn is_digest(&self) -> bool {
54        matches!(self.oci_reference_kind(), Some(OciReferenceKind::Digest))
55    }
56
57    /// Returns a canonical string form of the reference.
58    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/// Errors produced when parsing component source references.
113#[derive(Debug, thiserror::Error, PartialEq, Eq)]
114pub enum ComponentSourceRefError {
115    /// Reference cannot be empty.
116    #[error("component source reference cannot be empty")]
117    EmptyReference,
118    /// Reference must not contain whitespace.
119    #[error("component source reference must not contain whitespace")]
120    ContainsWhitespace,
121    /// Reference must use a supported scheme.
122    #[error("component source reference must use oci://, repo://, store://, or file://")]
123    InvalidScheme,
124    /// Reference is missing the required locator after the scheme.
125    #[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}