use_docker_registry/
lib.rs1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerRegistryError {
10 Empty,
12 InvalidRegistry,
14 InvalidRepository,
16}
17
18impl fmt::Display for DockerRegistryError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Empty => formatter.write_str("Docker registry reference cannot be empty"),
22 Self::InvalidRegistry => formatter.write_str("invalid Docker registry host"),
23 Self::InvalidRepository => formatter.write_str("invalid Docker repository path"),
24 }
25 }
26}
27
28impl Error for DockerRegistryError {}
29
30#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct DockerRegistry(String);
33
34impl DockerRegistry {
35 pub fn new(value: impl AsRef<str>) -> Result<Self, DockerRegistryError> {
37 let trimmed = value.as_ref().trim();
38 validate_registry(trimmed)?;
39 Ok(Self(trimmed.to_string()))
40 }
41
42 #[must_use]
44 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47}
48
49impl AsRef<str> for DockerRegistry {
50 fn as_ref(&self) -> &str {
51 self.as_str()
52 }
53}
54
55impl fmt::Display for DockerRegistry {
56 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57 formatter.write_str(self.as_str())
58 }
59}
60
61impl FromStr for DockerRegistry {
62 type Err = DockerRegistryError;
63
64 fn from_str(value: &str) -> Result<Self, Self::Err> {
65 Self::new(value)
66 }
67}
68
69#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
71pub struct DockerRepositoryPath(String);
72
73impl DockerRepositoryPath {
74 pub fn new(value: impl AsRef<str>) -> Result<Self, DockerRegistryError> {
76 let trimmed = value.as_ref().trim();
77 validate_repository(trimmed)?;
78 Ok(Self(trimmed.to_string()))
79 }
80
81 #[must_use]
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86
87 #[must_use]
89 pub fn repository(&self) -> &str {
90 self.as_str()
91 .rsplit_once('/')
92 .map_or(self.as_str(), |(_, repository)| repository)
93 }
94}
95
96impl AsRef<str> for DockerRepositoryPath {
97 fn as_ref(&self) -> &str {
98 self.as_str()
99 }
100}
101
102impl fmt::Display for DockerRepositoryPath {
103 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104 formatter.write_str(self.as_str())
105 }
106}
107
108impl FromStr for DockerRepositoryPath {
109 type Err = DockerRegistryError;
110
111 fn from_str(value: &str) -> Result<Self, Self::Err> {
112 Self::new(value)
113 }
114}
115
116#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
118pub struct RegistryImagePath {
119 registry: Option<DockerRegistry>,
120 repository: DockerRepositoryPath,
121}
122
123impl RegistryImagePath {
124 #[must_use]
126 pub fn new(registry: Option<DockerRegistry>, repository: DockerRepositoryPath) -> Self {
127 Self {
128 registry,
129 repository,
130 }
131 }
132
133 #[must_use]
135 pub fn registry(&self) -> Option<&DockerRegistry> {
136 self.registry.as_ref()
137 }
138
139 #[must_use]
141 pub fn repository_path(&self) -> &DockerRepositoryPath {
142 &self.repository
143 }
144}
145
146impl fmt::Display for RegistryImagePath {
147 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148 if let Some(registry) = &self.registry {
149 write!(formatter, "{registry}/{}", self.repository)
150 } else {
151 fmt::Display::fmt(&self.repository, formatter)
152 }
153 }
154}
155
156fn validate_registry(value: &str) -> Result<(), DockerRegistryError> {
157 if value.is_empty() {
158 return Err(DockerRegistryError::Empty);
159 }
160 if value.contains("//")
161 || value.contains('/')
162 || value.chars().any(char::is_whitespace)
163 || !value
164 .bytes()
165 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b':'))
166 || value.starts_with(['.', '-', ':'])
167 || value.ends_with(['.', '-', ':'])
168 {
169 Err(DockerRegistryError::InvalidRegistry)
170 } else {
171 Ok(())
172 }
173}
174
175fn validate_repository(value: &str) -> Result<(), DockerRegistryError> {
176 if value.is_empty() {
177 return Err(DockerRegistryError::Empty);
178 }
179 if value
180 .split('/')
181 .any(|component| !is_valid_component(component))
182 {
183 Err(DockerRegistryError::InvalidRepository)
184 } else {
185 Ok(())
186 }
187}
188
189fn is_valid_component(value: &str) -> bool {
190 !value.is_empty()
191 && value.bytes().all(|byte| {
192 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
193 })
194 && value
195 .bytes()
196 .next()
197 .is_some_and(|byte| byte.is_ascii_alphanumeric())
198 && value
199 .bytes()
200 .last()
201 .is_some_and(|byte| byte.is_ascii_alphanumeric())
202}
203
204#[cfg(test)]
205mod tests {
206 use super::{DockerRegistry, DockerRepositoryPath, RegistryImagePath};
207
208 #[test]
209 fn renders_registry_image_paths() -> Result<(), Box<dyn std::error::Error>> {
210 let registry = DockerRegistry::new("ghcr.io")?;
211 let repository = DockerRepositoryPath::new("rustuse/app")?;
212 let path = RegistryImagePath::new(Some(registry), repository);
213
214 assert_eq!(path.to_string(), "ghcr.io/rustuse/app");
215 assert_eq!(path.repository_path().repository(), "app");
216 Ok(())
217 }
218}