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#[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct RegistryHost(String);
33
34impl RegistryHost {
35 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
62pub struct Namespace(String);
63
64impl Namespace {
65 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 #[must_use]
73 pub fn as_str(&self) -> &str {
74 &self.0
75 }
76}
77
78#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
80pub struct RepositoryName(String);
81
82impl RepositoryName {
83 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 #[must_use]
100 pub fn as_str(&self) -> &str {
101 &self.0
102 }
103
104 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
127pub struct BlobReference {
128 repository: RepositoryName,
129 digest: OciDigest,
130}
131
132impl BlobReference {
133 #[must_use]
135 pub const fn new(repository: RepositoryName, digest: OciDigest) -> Self {
136 Self { repository, digest }
137 }
138
139 #[must_use]
141 pub const fn repository(&self) -> &RepositoryName {
142 &self.repository
143 }
144
145 #[must_use]
147 pub const fn digest(&self) -> &OciDigest {
148 &self.digest
149 }
150}
151
152#[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
170pub struct TagReference {
171 repository: RepositoryName,
172 tag: OciTag,
173}
174
175impl TagReference {
176 #[must_use]
178 pub const fn new(repository: RepositoryName, tag: OciTag) -> Self {
179 Self { repository, tag }
180 }
181
182 #[must_use]
184 pub const fn tag(&self) -> &OciTag {
185 &self.tag
186 }
187}
188
189#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
191pub enum RouteAction {
192 Pull,
193 Push,
194}
195
196#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
198pub struct DistributionRoute {
199 action: RouteAction,
200 path: String,
201}
202
203impl DistributionRoute {
204 #[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 #[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 #[must_use]
224 pub const fn for_push(mut self) -> Self {
225 self.action = RouteAction::Push;
226 self
227 }
228
229 #[must_use]
231 pub const fn action(&self) -> RouteAction {
232 self.action
233 }
234
235 #[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}