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