Skip to main content

use_docker_registry/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when registry or repository reference text is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerRegistryError {
10    /// The value was empty after trimming.
11    Empty,
12    /// Registry host text used unsupported syntax.
13    InvalidRegistry,
14    /// Repository path text used unsupported syntax.
15    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/// A validated Docker registry host label.
31#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub struct DockerRegistry(String);
33
34impl DockerRegistry {
35    /// Creates a registry host label.
36    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    /// Returns the registry host text.
43    #[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/// A validated Docker repository path.
70#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
71pub struct DockerRepositoryPath(String);
72
73impl DockerRepositoryPath {
74    /// Creates a repository path.
75    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    /// Returns the repository path text.
82    #[must_use]
83    pub fn as_str(&self) -> &str {
84        &self.0
85    }
86
87    /// Returns the repository name after the final slash.
88    #[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/// A registry-qualified or local repository path.
117#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
118pub struct RegistryImagePath {
119    registry: Option<DockerRegistry>,
120    repository: DockerRepositoryPath,
121}
122
123impl RegistryImagePath {
124    /// Creates a registry image path from validated parts.
125    #[must_use]
126    pub fn new(registry: Option<DockerRegistry>, repository: DockerRepositoryPath) -> Self {
127        Self {
128            registry,
129            repository,
130        }
131    }
132
133    /// Returns the optional registry host.
134    #[must_use]
135    pub fn registry(&self) -> Option<&DockerRegistry> {
136        self.registry.as_ref()
137    }
138
139    /// Returns the repository path.
140    #[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}