systemprompt_models/profile/
oci_reference.rs1use std::fmt;
12use std::str::FromStr;
13
14use thiserror::Error;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct OciReference {
18 pub registry: String,
19 pub repository: String,
20 pub tag: Option<String>,
21 pub digest: Option<String>,
22}
23
24#[derive(Debug, Error, PartialEq, Eq)]
25pub enum OciReferenceError {
26 #[error("OCI reference is empty")]
27 Empty,
28
29 #[error("OCI reference must be registry/repository[:tag|@sha256:...], got: {0}")]
30 MissingRegistry(String),
31
32 #[error("OCI reference has an empty repository: {0}")]
33 EmptyRepository(String),
34
35 #[error("OCI repository path segment is not [a-z0-9] with . _ - separators: {0}")]
36 InvalidRepository(String),
37
38 #[error("OCI digest must be sha256:<64 lowercase hex>, got: {0}")]
39 InvalidDigest(String),
40
41 #[error("OCI tag must be [A-Za-z0-9_][A-Za-z0-9._-]{{0,127}}, got: {0}")]
42 InvalidTag(String),
43}
44
45impl OciReference {
46 #[must_use]
47 pub const fn is_pinned(&self) -> bool {
48 self.digest.is_some()
49 }
50}
51
52fn is_registry_host(segment: &str) -> bool {
53 segment == "localhost" || segment.contains('.') || segment.contains(':')
54}
55
56fn validate_repository(repository: &str) -> Result<(), OciReferenceError> {
57 let invalid = || OciReferenceError::InvalidRepository(repository.to_owned());
58 for segment in repository.split('/') {
59 if segment.is_empty() {
60 return Err(invalid());
61 }
62 let edge_ok = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit();
63 let first_ok = segment.chars().next().is_some_and(edge_ok);
64 let last_ok = segment.chars().next_back().is_some_and(edge_ok);
65 let body_ok = segment
66 .chars()
67 .all(|c| edge_ok(c) || matches!(c, '.' | '_' | '-'));
68 if !(first_ok && last_ok && body_ok) {
69 return Err(invalid());
70 }
71 }
72 Ok(())
73}
74
75fn validate_tag(tag: &str) -> Result<(), OciReferenceError> {
76 let invalid = || OciReferenceError::InvalidTag(tag.to_owned());
77 if tag.is_empty() || tag.len() > 128 {
78 return Err(invalid());
79 }
80 let first_ok = tag
81 .chars()
82 .next()
83 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
84 let body_ok = tag
85 .chars()
86 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
87 if first_ok && body_ok {
88 Ok(())
89 } else {
90 Err(invalid())
91 }
92}
93
94fn validate_digest(digest: &str) -> Result<(), OciReferenceError> {
95 let hex = digest
96 .strip_prefix("sha256:")
97 .ok_or_else(|| OciReferenceError::InvalidDigest(digest.to_owned()))?;
98 let lower_hex = hex
99 .chars()
100 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c));
101 if hex.len() == 64 && lower_hex {
102 Ok(())
103 } else {
104 Err(OciReferenceError::InvalidDigest(digest.to_owned()))
105 }
106}
107
108impl FromStr for OciReference {
109 type Err = OciReferenceError;
110
111 fn from_str(s: &str) -> Result<Self, Self::Err> {
112 let raw = s.strip_prefix("oci://").unwrap_or(s);
113 if raw.is_empty() {
114 return Err(OciReferenceError::Empty);
115 }
116
117 let (name, digest) = match raw.split_once('@') {
118 Some((name, digest)) => {
119 validate_digest(digest)?;
120 (name, Some(digest.to_owned()))
121 },
122 None => (raw, None),
123 };
124
125 let (name, tag) = match name.rfind(':') {
126 Some(idx) if !name[idx + 1..].contains('/') => {
127 let tag = &name[idx + 1..];
128 validate_tag(tag)?;
129 (&name[..idx], Some(tag.to_owned()))
130 },
131 _ => (name, None),
132 };
133
134 let (registry, repository) = name
135 .split_once('/')
136 .ok_or_else(|| OciReferenceError::MissingRegistry(s.to_owned()))?;
137
138 if !is_registry_host(registry) {
139 return Err(OciReferenceError::MissingRegistry(s.to_owned()));
140 }
141 if repository.is_empty() {
142 return Err(OciReferenceError::EmptyRepository(s.to_owned()));
143 }
144 validate_repository(repository)?;
145
146 Ok(Self {
147 registry: registry.to_owned(),
148 repository: repository.to_owned(),
149 tag,
150 digest,
151 })
152 }
153}
154
155impl fmt::Display for OciReference {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 write!(f, "{}/{}", self.registry, self.repository)?;
158 if let Some(tag) = &self.tag {
159 write!(f, ":{tag}")?;
160 }
161 if let Some(digest) = &self.digest {
162 write!(f, "@{digest}")?;
163 }
164 Ok(())
165 }
166}