Skip to main content

use_oci_distribution/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_oci_digest::OciDigest;
8use use_oci_tag::OciTag;
9
10/// Errors returned when distribution identifiers are invalid.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum DistributionError {
13    Empty,
14    InvalidHost,
15    InvalidRepository,
16}
17
18impl fmt::Display for DistributionError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("OCI distribution value cannot be empty"),
22            Self::InvalidHost => formatter.write_str("invalid OCI registry host"),
23            Self::InvalidRepository => formatter.write_str("invalid OCI repository name"),
24        }
25    }
26}
27
28impl Error for DistributionError {}
29
30/// A registry host label.
31#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct RegistryHost(String);
33
34impl RegistryHost {
35    /// Creates a registry host label.
36    pub fn new(value: impl AsRef<str>) -> Result<Self, DistributionError> {
37        let trimmed = value.as_ref().trim();
38        if trimmed.is_empty() {
39            return Err(DistributionError::Empty);
40        }
41        if trimmed.contains('/') || trimmed.chars().any(char::is_whitespace) {
42            return Err(DistributionError::InvalidHost);
43        }
44        Ok(Self(trimmed.to_string()))
45    }
46
47    /// Returns the host text.
48    #[must_use]
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl fmt::Display for RegistryHost {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        formatter.write_str(self.as_str())
57    }
58}
59
60/// A slash-separated repository namespace.
61#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
62pub struct Namespace(String);
63
64impl Namespace {
65    /// Creates a namespace label.
66    pub fn new(value: impl AsRef<str>) -> Result<Self, DistributionError> {
67        let repository = RepositoryName::new(value)?;
68        Ok(Self(repository.into_string()))
69    }
70
71    /// Returns the namespace text.
72    #[must_use]
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78/// A validated repository name.
79#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
80pub struct RepositoryName(String);
81
82impl RepositoryName {
83    /// Creates a repository name.
84    pub fn new(value: impl AsRef<str>) -> Result<Self, DistributionError> {
85        let trimmed = value.as_ref().trim();
86        if trimmed.is_empty() {
87            return Err(DistributionError::Empty);
88        }
89        if trimmed
90            .split('/')
91            .any(|component| !is_valid_component(component))
92        {
93            return Err(DistributionError::InvalidRepository);
94        }
95        Ok(Self(trimmed.to_string()))
96    }
97
98    /// Returns the repository text.
99    #[must_use]
100    pub fn as_str(&self) -> &str {
101        &self.0
102    }
103
104    /// Consumes the repository and returns the owned string.
105    #[must_use]
106    pub fn into_string(self) -> String {
107        self.0
108    }
109}
110
111impl fmt::Display for RepositoryName {
112    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113        formatter.write_str(self.as_str())
114    }
115}
116
117impl FromStr for RepositoryName {
118    type Err = DistributionError;
119
120    fn from_str(value: &str) -> Result<Self, Self::Err> {
121        Self::new(value)
122    }
123}
124
125/// A blob reference by digest.
126#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
127pub struct BlobReference {
128    repository: RepositoryName,
129    digest: OciDigest,
130}
131
132impl BlobReference {
133    /// Creates a blob reference.
134    #[must_use]
135    pub const fn new(repository: RepositoryName, digest: OciDigest) -> Self {
136        Self { repository, digest }
137    }
138
139    /// Returns the repository.
140    #[must_use]
141    pub const fn repository(&self) -> &RepositoryName {
142        &self.repository
143    }
144
145    /// Returns the digest.
146    #[must_use]
147    pub const fn digest(&self) -> &OciDigest {
148        &self.digest
149    }
150}
151
152/// A manifest reference by tag or digest.
153#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub enum ManifestReference {
155    Tag(OciTag),
156    Digest(OciDigest),
157}
158
159impl fmt::Display for ManifestReference {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Self::Tag(tag) => tag.fmt(formatter),
163            Self::Digest(digest) => digest.fmt(formatter),
164        }
165    }
166}
167
168/// A tag reference.
169#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
170pub struct TagReference {
171    repository: RepositoryName,
172    tag: OciTag,
173}
174
175impl TagReference {
176    /// Creates a tag reference.
177    #[must_use]
178    pub const fn new(repository: RepositoryName, tag: OciTag) -> Self {
179        Self { repository, tag }
180    }
181
182    /// Returns the tag.
183    #[must_use]
184    pub const fn tag(&self) -> &OciTag {
185        &self.tag
186    }
187}
188
189/// Pull or push route action metadata.
190#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
191pub enum RouteAction {
192    Pull,
193    Push,
194}
195
196/// A distribution route path. This type performs no HTTP calls.
197#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
198pub struct DistributionRoute {
199    action: RouteAction,
200    path: String,
201}
202
203impl DistributionRoute {
204    /// Creates a blob route path.
205    #[must_use]
206    pub fn blob(repository: &RepositoryName, digest: &OciDigest) -> Self {
207        Self {
208            action: RouteAction::Pull,
209            path: format!("/v2/{repository}/blobs/{digest}"),
210        }
211    }
212
213    /// Creates a manifest route path.
214    #[must_use]
215    pub fn manifest(repository: &RepositoryName, reference: &ManifestReference) -> Self {
216        Self {
217            action: RouteAction::Pull,
218            path: format!("/v2/{repository}/manifests/{reference}"),
219        }
220    }
221
222    /// Marks the route as push metadata.
223    #[must_use]
224    pub const fn for_push(mut self) -> Self {
225        self.action = RouteAction::Push;
226        self
227    }
228
229    /// Returns the route action.
230    #[must_use]
231    pub const fn action(&self) -> RouteAction {
232        self.action
233    }
234
235    /// Returns the route path.
236    #[must_use]
237    pub fn path(&self) -> &str {
238        &self.path
239    }
240}
241
242fn is_valid_component(value: &str) -> bool {
243    !value.is_empty()
244        && value.bytes().all(|byte| {
245            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
246        })
247        && value
248            .bytes()
249            .next()
250            .is_some_and(|byte| byte.is_ascii_alphanumeric())
251        && value
252            .bytes()
253            .last()
254            .is_some_and(|byte| byte.is_ascii_alphanumeric())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{
260        DistributionError, DistributionRoute, ManifestReference, RepositoryName, RouteAction,
261    };
262    use use_oci_digest::OciDigest;
263    use use_oci_tag::OciTag;
264
265    const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
266
267    #[test]
268    fn builds_distribution_paths_without_http() -> Result<(), Box<dyn std::error::Error>> {
269        let repository = RepositoryName::new("rustuse/app")?;
270        let digest: OciDigest = format!("sha256:{SHA}").parse()?;
271        let route = DistributionRoute::manifest(&repository, &ManifestReference::Digest(digest));
272        let tag_route = DistributionRoute::manifest(
273            &repository,
274            &ManifestReference::Tag(OciTag::new("latest")?),
275        )
276        .for_push();
277
278        assert_eq!(
279            route.path(),
280            format!("/v2/rustuse/app/manifests/sha256:{SHA}")
281        );
282        assert_eq!(tag_route.action(), RouteAction::Push);
283        assert_eq!(
284            RepositoryName::new("Bad/Name"),
285            Err(DistributionError::InvalidRepository)
286        );
287        Ok(())
288    }
289}