ferro_oci_server/
reference.rs1use std::fmt;
18use std::str::FromStr;
19
20use ferro_blob_store::Digest;
21
22use crate::error::{OciError, OciErrorCode};
23
24pub const MAX_NAME_LENGTH: usize = 255;
26
27pub const MAX_TAG_LENGTH: usize = 128;
29
30pub fn validate_name(name: &str) -> Result<(), OciError> {
38 if name.is_empty() {
39 return Err(OciError::new(
40 OciErrorCode::NameInvalid,
41 "repository name must not be empty",
42 ));
43 }
44 if name.len() > MAX_NAME_LENGTH {
45 return Err(OciError::new(
46 OciErrorCode::NameInvalid,
47 format!("repository name exceeds {MAX_NAME_LENGTH} characters"),
48 ));
49 }
50
51 for component in name.split('/') {
58 validate_component(component)
59 .map_err(|msg| OciError::new(OciErrorCode::NameInvalid, msg))?;
60 }
61 Ok(())
62}
63
64fn validate_component(component: &str) -> Result<(), String> {
65 if component.is_empty() {
66 return Err("path component must not be empty".to_owned());
67 }
68 let bytes = component.as_bytes();
69 if !is_alnum(bytes[0]) {
71 return Err(format!("component `{component}` must start with [a-z0-9]"));
72 }
73 if !is_alnum(bytes[bytes.len() - 1]) {
75 return Err(format!("component `{component}` must end with [a-z0-9]"));
76 }
77
78 let mut i = 0;
81 while i < bytes.len() {
82 let c = bytes[i];
83 if is_alnum(c) {
84 i += 1;
85 continue;
86 }
87 let start = i;
89 while i < bytes.len() && !is_alnum(bytes[i]) {
90 i += 1;
91 }
92 let sep = &component[start..i];
93 if !is_valid_separator(sep) {
94 return Err(format!(
95 "component `{component}` contains invalid separator `{sep}`"
96 ));
97 }
98 }
99 Ok(())
100}
101
102const fn is_alnum(b: u8) -> bool {
103 b.is_ascii_digit() || b.is_ascii_lowercase()
104}
105
106fn is_valid_separator(s: &str) -> bool {
107 if s == "." || s == "_" || s == "__" {
108 return true;
109 }
110 !s.is_empty() && s.bytes().all(|b| b == b'-')
112}
113
114fn is_valid_tag(tag: &str) -> bool {
118 if tag.is_empty() || tag.len() > MAX_TAG_LENGTH {
119 return false;
120 }
121 let bytes = tag.as_bytes();
122 let first_ok = bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_';
123 if !first_ok {
124 return false;
125 }
126 bytes[1..]
127 .iter()
128 .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Hash)]
133pub enum Reference {
134 Tag(String),
136 Digest(Digest),
138}
139
140impl Reference {
141 #[must_use]
143 pub const fn is_tag(&self) -> bool {
144 matches!(self, Self::Tag(_))
145 }
146
147 #[must_use]
149 pub const fn is_digest(&self) -> bool {
150 matches!(self, Self::Digest(_))
151 }
152
153 #[must_use]
155 pub const fn as_digest(&self) -> Option<&Digest> {
156 match self {
157 Self::Digest(d) => Some(d),
158 Self::Tag(_) => None,
159 }
160 }
161
162 #[must_use]
164 pub fn as_tag(&self) -> Option<&str> {
165 match self {
166 Self::Tag(t) => Some(t.as_str()),
167 Self::Digest(_) => None,
168 }
169 }
170}
171
172impl fmt::Display for Reference {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 Self::Tag(t) => f.write_str(t),
176 Self::Digest(d) => fmt::Display::fmt(d, f),
177 }
178 }
179}
180
181impl FromStr for Reference {
182 type Err = OciError;
183
184 fn from_str(s: &str) -> Result<Self, Self::Err> {
185 if let Some((algo, _hex)) = s.split_once(':') {
188 if algo == "sha256" || algo == "sha512" {
189 let d: Digest = s.parse().map_err(|e: ferro_blob_store::DigestParseError| {
190 OciError::new(OciErrorCode::DigestInvalid, e.to_string())
191 })?;
192 return Ok(Self::Digest(d));
193 }
194 return Err(OciError::new(
196 OciErrorCode::ManifestInvalid,
197 format!("invalid reference: `{s}`"),
198 ));
199 }
200 if !is_valid_tag(s) {
201 return Err(OciError::new(
202 OciErrorCode::ManifestInvalid,
203 format!("invalid tag: `{s}`"),
204 ));
205 }
206 Ok(Self::Tag(s.to_owned()))
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::{Reference, validate_name};
213
214 #[test]
215 fn simple_single_component_name_is_valid() {
216 assert!(validate_name("alpine").is_ok());
217 }
218
219 #[test]
220 fn nested_path_name_is_valid() {
221 assert!(validate_name("library/alpine").is_ok());
222 assert!(validate_name("my-org/sub-project/app").is_ok());
223 }
224
225 #[test]
226 fn underscore_and_dot_and_dash_separators_are_valid() {
227 assert!(validate_name("foo_bar").is_ok());
228 assert!(validate_name("foo__bar").is_ok());
229 assert!(validate_name("foo.bar").is_ok());
230 assert!(validate_name("foo-bar").is_ok());
231 assert!(validate_name("foo---bar").is_ok());
232 }
233
234 #[test]
235 fn uppercase_is_rejected() {
236 let err = validate_name("Alpine").expect_err("uppercase invalid");
237 assert_eq!(err.code.as_str(), "NAME_INVALID");
238 }
239
240 #[test]
241 fn leading_separator_is_rejected() {
242 assert!(validate_name("-alpine").is_err());
243 assert!(validate_name(".alpine").is_err());
244 assert!(validate_name("_alpine").is_err());
245 }
246
247 #[test]
248 fn trailing_separator_is_rejected() {
249 assert!(validate_name("alpine-").is_err());
250 assert!(validate_name("alpine.").is_err());
251 }
252
253 #[test]
254 fn empty_component_is_rejected() {
255 assert!(validate_name("foo//bar").is_err());
256 assert!(validate_name("/foo").is_err());
257 assert!(validate_name("foo/").is_err());
258 }
259
260 #[test]
261 fn too_long_name_is_rejected() {
262 let s = "a".repeat(256);
263 assert!(validate_name(&s).is_err());
264 }
265
266 #[test]
267 fn tag_reference_parses() {
268 let r: Reference = "v1.2.3-rc1".parse().expect("tag parse");
269 assert!(r.is_tag());
270 assert_eq!(r.as_tag(), Some("v1.2.3-rc1"));
271 }
272
273 #[test]
274 fn digest_reference_parses() {
275 let digest = format!("sha256:{}", "a".repeat(64));
276 let r: Reference = digest.parse().expect("digest parse");
277 assert!(r.is_digest());
278 assert_eq!(r.to_string(), digest);
279 }
280
281 #[test]
282 fn bad_digest_reference_is_rejected() {
283 assert!("sha256:beef".parse::<Reference>().is_err());
285 }
286
287 #[test]
288 fn tag_with_colon_is_rejected_as_invalid_reference() {
289 assert!("some:weird".parse::<Reference>().is_err());
291 }
292}