Skip to main content

systemprompt_models/profile/
oci_reference.rs

1//! Parser for the `registry/repository[:tag|@sha256:<digest>]` references used
2//! by OCI services-bundle sources.
3//!
4//! The registry host is mandatory: an unqualified reference such as
5//! `alpine:3` would otherwise resolve against an implicit default registry,
6//! which is not a decision a profile should make silently.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::fmt;
12use std::str::FromStr;
13
14use thiserror::Error;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct OciReference {
18    pub registry: String,
19    pub repository: String,
20    pub tag: Option<String>,
21    pub digest: Option<String>,
22}
23
24#[derive(Debug, Error, PartialEq, Eq)]
25pub enum OciReferenceError {
26    #[error("OCI reference is empty")]
27    Empty,
28
29    #[error("OCI reference must be registry/repository[:tag|@sha256:...], got: {0}")]
30    MissingRegistry(String),
31
32    #[error("OCI reference has an empty repository: {0}")]
33    EmptyRepository(String),
34
35    #[error("OCI repository path segment is not [a-z0-9] with . _ - separators: {0}")]
36    InvalidRepository(String),
37
38    #[error("OCI digest must be sha256:<64 lowercase hex>, got: {0}")]
39    InvalidDigest(String),
40
41    #[error("OCI tag must be [A-Za-z0-9_][A-Za-z0-9._-]{{0,127}}, got: {0}")]
42    InvalidTag(String),
43}
44
45impl OciReference {
46    #[must_use]
47    pub const fn is_pinned(&self) -> bool {
48        self.digest.is_some()
49    }
50}
51
52fn is_registry_host(segment: &str) -> bool {
53    segment == "localhost" || segment.contains('.') || segment.contains(':')
54}
55
56fn validate_repository(repository: &str) -> Result<(), OciReferenceError> {
57    let invalid = || OciReferenceError::InvalidRepository(repository.to_owned());
58    for segment in repository.split('/') {
59        if segment.is_empty() {
60            return Err(invalid());
61        }
62        let edge_ok = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit();
63        let first_ok = segment.chars().next().is_some_and(edge_ok);
64        let last_ok = segment.chars().next_back().is_some_and(edge_ok);
65        let body_ok = segment
66            .chars()
67            .all(|c| edge_ok(c) || matches!(c, '.' | '_' | '-'));
68        if !(first_ok && last_ok && body_ok) {
69            return Err(invalid());
70        }
71    }
72    Ok(())
73}
74
75fn validate_tag(tag: &str) -> Result<(), OciReferenceError> {
76    let invalid = || OciReferenceError::InvalidTag(tag.to_owned());
77    if tag.is_empty() || tag.len() > 128 {
78        return Err(invalid());
79    }
80    let first_ok = tag
81        .chars()
82        .next()
83        .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
84    let body_ok = tag
85        .chars()
86        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
87    if first_ok && body_ok {
88        Ok(())
89    } else {
90        Err(invalid())
91    }
92}
93
94fn validate_digest(digest: &str) -> Result<(), OciReferenceError> {
95    let hex = digest
96        .strip_prefix("sha256:")
97        .ok_or_else(|| OciReferenceError::InvalidDigest(digest.to_owned()))?;
98    let lower_hex = hex
99        .chars()
100        .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c));
101    if hex.len() == 64 && lower_hex {
102        Ok(())
103    } else {
104        Err(OciReferenceError::InvalidDigest(digest.to_owned()))
105    }
106}
107
108impl FromStr for OciReference {
109    type Err = OciReferenceError;
110
111    fn from_str(s: &str) -> Result<Self, Self::Err> {
112        let raw = s.strip_prefix("oci://").unwrap_or(s);
113        if raw.is_empty() {
114            return Err(OciReferenceError::Empty);
115        }
116
117        let (name, digest) = match raw.split_once('@') {
118            Some((name, digest)) => {
119                validate_digest(digest)?;
120                (name, Some(digest.to_owned()))
121            },
122            None => (raw, None),
123        };
124
125        let (name, tag) = match name.rfind(':') {
126            Some(idx) if !name[idx + 1..].contains('/') => {
127                let tag = &name[idx + 1..];
128                validate_tag(tag)?;
129                (&name[..idx], Some(tag.to_owned()))
130            },
131            _ => (name, None),
132        };
133
134        let (registry, repository) = name
135            .split_once('/')
136            .ok_or_else(|| OciReferenceError::MissingRegistry(s.to_owned()))?;
137
138        if !is_registry_host(registry) {
139            return Err(OciReferenceError::MissingRegistry(s.to_owned()));
140        }
141        if repository.is_empty() {
142            return Err(OciReferenceError::EmptyRepository(s.to_owned()));
143        }
144        validate_repository(repository)?;
145
146        Ok(Self {
147            registry: registry.to_owned(),
148            repository: repository.to_owned(),
149            tag,
150            digest,
151        })
152    }
153}
154
155impl fmt::Display for OciReference {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(f, "{}/{}", self.registry, self.repository)?;
158        if let Some(tag) = &self.tag {
159            write!(f, ":{tag}")?;
160        }
161        if let Some(digest) = &self.digest {
162            write!(f, "@{digest}")?;
163        }
164        Ok(())
165    }
166}