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::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/// An OCI registry repository without a tag or digest selector.
29#[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;
373
374    use super::{Reference, ReferenceError, Repository, Selector};
375
376    #[test]
377    fn parses_tags_and_digests() {
378        let tagged = Reference::parse("oci://example.com/team/image:v1.2").unwrap();
379        assert_eq!(tagged.registry(), "example.com");
380        assert_eq!(tagged.repository(), "team/image");
381        assert_eq!(tagged.selector(), Selector::Tag("v1.2"));
382
383        let digest = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
384        let input = format!("oci://example.com/image@{digest}");
385        let pinned = Reference::parse(&input).unwrap();
386        let mut path = [0; 128];
387        assert_eq!(
388            pinned.manifest_path(&mut path).unwrap(),
389            format!("/v2/image/manifests/{digest}")
390        );
391    }
392
393    #[test]
394    fn rejects_invalid_names_and_small_buffers() {
395        assert_eq!(
396            Reference::parse("oci://example.com/Upper:tag"),
397            Err(ReferenceError::InvalidRepository)
398        );
399        let reference = Reference::parse("oci://example.com/image:tag").unwrap();
400        assert_eq!(
401            reference.manifest_path(&mut [0; 4]),
402            Err(ReferenceError::BufferTooSmall)
403        );
404    }
405
406    #[test]
407    fn builds_references_and_tag_pages_from_repositories() {
408        let repository = Repository::parse("oci://example.com/team/image").unwrap();
409        assert_eq!(repository.registry(), "example.com");
410        assert_eq!(repository.repository(), "team/image");
411        assert_eq!(
412            repository.with_tag("latest").unwrap().selector(),
413            Selector::Tag("latest")
414        );
415        let mut path = [0; 64];
416        assert_eq!(
417            repository.tags_page_path(100, &mut path).unwrap(),
418            "/v2/team/image/tags/list?n=100"
419        );
420        assert_eq!(
421            Repository::parse("oci://example.com/image:tag"),
422            Err(ReferenceError::InvalidRepository)
423        );
424    }
425}