Skip to main content

oci_zero/
reference.rs

1//! Borrowed OCI references and allocation-free registry paths.
2
3use 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/// An OCI registry repository without a tag or digest selector.
27#[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    // Dots, underscores and slashes separate nonempty alphanumeric-ended runs.
268    // Hyphens may repeat inside a run, but cannot border another separator.
269    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}