1use core::fmt::{self, Write as _};
4
5use crate::{
6 buffer::BufferWriter,
7 digest::{Digest, DigestError},
8};
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Display)]
11pub enum Scheme {
12 #[display("http")]
13 Http,
14 #[display("https")]
15 Https,
16}
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Display)]
19pub enum Selector<'a> {
20 #[display("{_0}")]
21 Tag(&'a str),
22 #[display("{_0}")]
23 Digest(Digest),
24}
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub struct Repository<'a> {
29 registry: &'a str,
30 repository: &'a str,
31}
32
33impl<'a> Repository<'a> {
34 pub fn parse(input: &'a str) -> Result<Self, ReferenceError> {
35 let value = input
36 .strip_prefix("oci://")
37 .ok_or(ReferenceError::InvalidScheme)?;
38 let (registry, repository) = value
39 .split_once('/')
40 .ok_or(ReferenceError::MissingRepository)?;
41 validate_registry(registry)?;
42 validate_repository(repository)?;
43 Ok(Self {
44 registry,
45 repository,
46 })
47 }
48
49 pub const fn registry(&self) -> &'a str {
50 self.registry
51 }
52
53 pub const fn repository(&self) -> &'a str {
54 self.repository
55 }
56
57 pub fn with_tag(self, tag: &'a str) -> Result<Reference<'a>, ReferenceError> {
58 validate_tag(tag)?;
59 Ok(Reference {
60 registry: self.registry,
61 repository: self.repository,
62 selector: Selector::Tag(tag),
63 })
64 }
65
66 pub const fn with_digest(self, digest: Digest) -> Reference<'a> {
67 Reference {
68 registry: self.registry,
69 repository: self.repository,
70 selector: Selector::Digest(digest),
71 }
72 }
73
74 pub fn manifest_path<'buffer>(
75 &self,
76 selector: Selector<'_>,
77 buffer: &'buffer mut [u8],
78 ) -> Result<&'buffer str, ReferenceError> {
79 write_path(
80 buffer,
81 format_args!("/v2/{}/manifests/{selector}", self.repository),
82 )
83 }
84
85 pub fn manifest_digest_path<'buffer>(
86 &self,
87 digest: Digest,
88 buffer: &'buffer mut [u8],
89 ) -> Result<&'buffer str, ReferenceError> {
90 write_path(
91 buffer,
92 format_args!("/v2/{}/manifests/{digest}", self.repository),
93 )
94 }
95
96 pub fn blob_path<'buffer>(
97 &self,
98 digest: Digest,
99 buffer: &'buffer mut [u8],
100 ) -> Result<&'buffer str, ReferenceError> {
101 write_path(
102 buffer,
103 format_args!("/v2/{}/blobs/{digest}", self.repository),
104 )
105 }
106
107 pub fn referrers_path<'buffer>(
108 &self,
109 digest: Digest,
110 buffer: &'buffer mut [u8],
111 ) -> Result<&'buffer str, ReferenceError> {
112 write_path(
113 buffer,
114 format_args!("/v2/{}/referrers/{digest}", self.repository),
115 )
116 }
117
118 pub fn tags_path<'buffer>(
119 &self,
120 buffer: &'buffer mut [u8],
121 ) -> Result<&'buffer str, ReferenceError> {
122 write_path(buffer, format_args!("/v2/{}/tags/list", self.repository))
123 }
124
125 pub fn tags_page_path<'buffer>(
126 &self,
127 page_size: u16,
128 buffer: &'buffer mut [u8],
129 ) -> Result<&'buffer str, ReferenceError> {
130 write_path(
131 buffer,
132 format_args!("/v2/{}/tags/list?n={page_size}", self.repository),
133 )
134 }
135}
136
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub struct Reference<'a> {
139 registry: &'a str,
140 repository: &'a str,
141 selector: Selector<'a>,
142}
143
144impl<'a> Reference<'a> {
145 pub fn parse(input: &'a str) -> Result<Self, ReferenceError> {
146 let value = input
147 .strip_prefix("oci://")
148 .ok_or(ReferenceError::InvalidScheme)?;
149 let (registry, repository_and_selector) = value
150 .split_once('/')
151 .ok_or(ReferenceError::MissingRepository)?;
152 validate_registry(registry)?;
153
154 let (repository, selector) =
155 if let Some((repository, digest)) = repository_and_selector.rsplit_once('@') {
156 let digest = Digest::parse(digest).map_err(ReferenceError::Digest)?;
157 (repository, Selector::Digest(digest))
158 } else {
159 let (repository, tag) = repository_and_selector
160 .rsplit_once(':')
161 .ok_or(ReferenceError::MissingSelector)?;
162 validate_tag(tag)?;
163 (repository, Selector::Tag(tag))
164 };
165 validate_repository(repository)?;
166 Ok(Self {
167 registry,
168 repository,
169 selector,
170 })
171 }
172
173 pub const fn registry(&self) -> &'a str {
174 self.registry
175 }
176
177 pub const fn repository(&self) -> &'a str {
178 self.repository
179 }
180
181 pub const fn selector(&self) -> Selector<'a> {
182 self.selector
183 }
184
185 pub const fn as_repository(&self) -> Repository<'a> {
186 Repository {
187 registry: self.registry,
188 repository: self.repository,
189 }
190 }
191
192 pub fn manifest_path<'buffer>(
193 &self,
194 buffer: &'buffer mut [u8],
195 ) -> Result<&'buffer str, ReferenceError> {
196 self.as_repository().manifest_path(self.selector, buffer)
197 }
198
199 pub fn manifest_digest_path<'buffer>(
200 &self,
201 digest: Digest,
202 buffer: &'buffer mut [u8],
203 ) -> Result<&'buffer str, ReferenceError> {
204 self.as_repository().manifest_digest_path(digest, buffer)
205 }
206
207 pub fn blob_path<'buffer>(
208 &self,
209 digest: Digest,
210 buffer: &'buffer mut [u8],
211 ) -> Result<&'buffer str, ReferenceError> {
212 self.as_repository().blob_path(digest, buffer)
213 }
214
215 pub fn referrers_path<'buffer>(
216 &self,
217 digest: Digest,
218 buffer: &'buffer mut [u8],
219 ) -> Result<&'buffer str, ReferenceError> {
220 self.as_repository().referrers_path(digest, buffer)
221 }
222
223 pub fn tags_path<'buffer>(
224 &self,
225 buffer: &'buffer mut [u8],
226 ) -> Result<&'buffer str, ReferenceError> {
227 self.as_repository().tags_path(buffer)
228 }
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Display)]
232pub enum ReferenceError {
233 #[display("OCI reference must start with oci://")]
234 InvalidScheme,
235 #[display("invalid OCI registry authority")]
236 InvalidRegistry,
237 #[display("OCI reference is missing a repository")]
238 MissingRepository,
239 #[display("invalid OCI repository name")]
240 InvalidRepository,
241 #[display("OCI reference is missing a tag or digest")]
242 MissingSelector,
243 #[display("invalid OCI tag")]
244 InvalidTag,
245 #[display("invalid OCI digest")]
246 Digest(DigestError),
247 #[display("request path buffer is too small")]
248 BufferTooSmall,
249}
250
251fn validate_registry(registry: &str) -> Result<(), ReferenceError> {
252 if registry.is_empty()
253 || !registry.is_ascii()
254 || registry
255 .bytes()
256 .any(|byte| byte.is_ascii_whitespace() || matches!(byte, b'/' | b'@' | b'?' | b'#'))
257 {
258 return Err(ReferenceError::InvalidRegistry);
259 }
260 Ok(())
261}
262
263fn validate_repository(repository: &str) -> Result<(), ReferenceError> {
264 if repository.is_empty() || repository.len() > 255 {
265 return Err(ReferenceError::InvalidRepository);
266 }
267 for component in repository.split(['/', '.', '_']) {
270 let bytes = component.as_bytes();
271 if !bytes.first().copied().is_some_and(is_lower_alphanumeric)
272 || !bytes.last().copied().is_some_and(is_lower_alphanumeric)
273 || !bytes
274 .iter()
275 .all(|byte| is_lower_alphanumeric(*byte) || *byte == b'-')
276 {
277 return Err(ReferenceError::InvalidRepository);
278 }
279 }
280 Ok(())
281}
282
283fn validate_tag(tag: &str) -> Result<(), ReferenceError> {
284 let bytes = tag.as_bytes();
285 if bytes.is_empty()
286 || bytes.len() > 128
287 || !matches!(bytes[0], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')
288 || !bytes[1..].iter().all(
289 |byte| matches!(*byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'.' | b'-'),
290 )
291 {
292 return Err(ReferenceError::InvalidTag);
293 }
294 Ok(())
295}
296
297const fn is_lower_alphanumeric(byte: u8) -> bool {
298 matches!(byte, b'a'..=b'z' | b'0'..=b'9')
299}
300
301fn write_path<'a>(
302 buffer: &'a mut [u8],
303 arguments: fmt::Arguments<'_>,
304) -> Result<&'a str, ReferenceError> {
305 let mut writer = BufferWriter::new(buffer);
306 writer
307 .write_fmt(arguments)
308 .map_err(|_| ReferenceError::BufferTooSmall)?;
309 writer.finish().map_err(|_| ReferenceError::BufferTooSmall)
310}
311
312#[cfg(test)]
313mod tests {
314 use std::{format, string::ToString};
315
316 use super::{
317 validate_registry, validate_repository, validate_tag, Reference, ReferenceError,
318 Repository, Scheme, Selector,
319 };
320 use crate::digest::{Digest, DigestError};
321
322 const DIGEST_TEXT: &str =
323 "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
324
325 #[test]
326 fn parses_tags_and_digests() {
327 let tagged = Reference::parse("oci://example.com/team/image:v1.2").unwrap();
328 assert_eq!(tagged.registry(), "example.com");
329 assert_eq!(tagged.repository(), "team/image");
330 assert_eq!(tagged.selector(), Selector::Tag("v1.2"));
331
332 let input = format!("oci://example.com/image@{DIGEST_TEXT}");
333 let pinned = Reference::parse(&input).unwrap();
334 let mut path = [0; 128];
335 assert_eq!(
336 pinned.manifest_path(&mut path).unwrap(),
337 format!("/v2/image/manifests/{DIGEST_TEXT}")
338 );
339 }
340
341 #[test]
342 fn builds_every_repository_and_reference_path() {
343 let repository = Repository::parse("oci://example.com/team/image").unwrap();
344 let reference = repository.with_tag("latest").unwrap();
345 let digest = Digest::parse(DIGEST_TEXT).unwrap();
346 let mut path = [0; 128];
347
348 assert_eq!(
349 repository.manifest_digest_path(digest, &mut path).unwrap(),
350 format!("/v2/team/image/manifests/{DIGEST_TEXT}")
351 );
352 assert_eq!(
353 repository.blob_path(digest, &mut path).unwrap(),
354 format!("/v2/team/image/blobs/{DIGEST_TEXT}")
355 );
356 assert_eq!(
357 repository.referrers_path(digest, &mut path).unwrap(),
358 format!("/v2/team/image/referrers/{DIGEST_TEXT}")
359 );
360 assert_eq!(
361 repository.tags_path(&mut path).unwrap(),
362 "/v2/team/image/tags/list"
363 );
364
365 assert_eq!(
366 reference.manifest_digest_path(digest, &mut path).unwrap(),
367 format!("/v2/team/image/manifests/{DIGEST_TEXT}")
368 );
369 assert_eq!(
370 reference.blob_path(digest, &mut path).unwrap(),
371 format!("/v2/team/image/blobs/{DIGEST_TEXT}")
372 );
373 assert_eq!(
374 reference.referrers_path(digest, &mut path).unwrap(),
375 format!("/v2/team/image/referrers/{DIGEST_TEXT}")
376 );
377 assert_eq!(
378 reference.tags_path(&mut path).unwrap(),
379 "/v2/team/image/tags/list"
380 );
381 }
382
383 #[test]
384 fn rejects_invalid_names_and_small_buffers() {
385 assert_eq!(
386 Reference::parse("oci://example.com/Upper:tag"),
387 Err(ReferenceError::InvalidRepository)
388 );
389 let reference = Reference::parse("oci://example.com/image:tag").unwrap();
390 assert_eq!(
391 reference.manifest_path(&mut [0; 4]),
392 Err(ReferenceError::BufferTooSmall)
393 );
394 }
395
396 #[test]
397 fn builds_references_and_tag_pages_from_repositories() {
398 let repository = Repository::parse("oci://example.com/team/image").unwrap();
399 assert_eq!(repository.registry(), "example.com");
400 assert_eq!(repository.repository(), "team/image");
401 assert_eq!(
402 repository.with_tag("latest").unwrap().selector(),
403 Selector::Tag("latest")
404 );
405 let mut path = [0; 64];
406 assert_eq!(
407 repository.tags_page_path(100, &mut path).unwrap(),
408 "/v2/team/image/tags/list?n=100"
409 );
410 assert_eq!(
411 Repository::parse("oci://example.com/image:tag"),
412 Err(ReferenceError::InvalidRepository)
413 );
414 }
415
416 #[test]
417 fn rejects_each_invalid_registry_authority_class() {
418 for registry in [
419 "",
420 "exämple.com",
421 "example .com",
422 "example/com",
423 "a@b",
424 "a?b",
425 "a#b",
426 ] {
427 assert_eq!(
428 validate_registry(registry),
429 Err(ReferenceError::InvalidRegistry),
430 "registry={registry:?}"
431 );
432 }
433 assert_eq!(validate_registry("example.com:5000"), Ok(()));
434 }
435
436 #[test]
437 fn enforces_repository_length_boundaries() {
438 assert_eq!(
439 validate_repository(""),
440 Err(ReferenceError::InvalidRepository)
441 );
442 assert_eq!(validate_repository(&"a".repeat(255)), Ok(()));
443 assert_eq!(
444 validate_repository(&"a".repeat(256)),
445 Err(ReferenceError::InvalidRepository)
446 );
447 }
448
449 #[test]
450 fn accepts_repository_separators_only_between_alphanumerics() {
451 for repository in ["a", "ab", "abc", "a.b", "a_b", "a-b", "a--b", "a/b"] {
452 assert_eq!(
453 validate_repository(repository),
454 Ok(()),
455 "repository={repository:?}"
456 );
457 }
458
459 for repository in [
460 "/a", "a/", "a//b", ".a", "a.", "a..b", "a__b", "a-.b", "a---_b", "a+B",
461 ] {
462 assert_eq!(
463 validate_repository(repository),
464 Err(ReferenceError::InvalidRepository),
465 "repository={repository:?}"
466 );
467 }
468 }
469
470 #[test]
471 fn enforces_tag_character_and_length_rules() {
472 for tag in ["a", "_", "v1.2-alpha_3"] {
473 assert_eq!(validate_tag(tag), Ok(()), "tag={tag:?}");
474 }
475 assert_eq!(validate_tag(&format!("a{}", "b".repeat(127))), Ok(()));
476
477 for tag in [
478 "",
479 ".start",
480 "-start",
481 "has/slash",
482 "has:colon",
483 "nön-ascii",
484 ] {
485 assert_eq!(
486 validate_tag(tag),
487 Err(ReferenceError::InvalidTag),
488 "tag={tag:?}"
489 );
490 }
491 assert_eq!(
492 validate_tag(&"a".repeat(129)),
493 Err(ReferenceError::InvalidTag)
494 );
495 }
496
497 #[test]
498 fn formats_schemes_and_reference_errors() {
499 assert_eq!(Scheme::Http.to_string(), "http");
500 assert_eq!(Scheme::Https.to_string(), "https");
501
502 for (error, message) in [
503 (
504 ReferenceError::InvalidScheme,
505 "OCI reference must start with oci://",
506 ),
507 (
508 ReferenceError::InvalidRegistry,
509 "invalid OCI registry authority",
510 ),
511 (
512 ReferenceError::MissingRepository,
513 "OCI reference is missing a repository",
514 ),
515 (
516 ReferenceError::InvalidRepository,
517 "invalid OCI repository name",
518 ),
519 (
520 ReferenceError::MissingSelector,
521 "OCI reference is missing a tag or digest",
522 ),
523 (ReferenceError::InvalidTag, "invalid OCI tag"),
524 (
525 ReferenceError::Digest(DigestError::InvalidEncoding),
526 "invalid OCI digest",
527 ),
528 (
529 ReferenceError::BufferTooSmall,
530 "request path buffer is too small",
531 ),
532 ] {
533 assert_eq!(error.to_string(), message);
534 }
535 }
536}