Skip to main content

docker_registry_client/
image.rs

1use serde::{
2    Deserialize,
3    Serialize,
4};
5use tracing::error;
6
7#[expect(
8    clippy::module_name_repetitions,
9    reason = "This module is about image_names so its fine to repeat the name"
10)]
11pub mod image_name;
12pub mod registry;
13
14use image_name::ImageName;
15use registry::Registry;
16
17#[derive(Debug)]
18pub enum FromStrError {
19    MissingFirstComponent,
20    UnsupportedImageName(String),
21    ParseImageName(image_name::FromStrError),
22    MissingRegistry,
23    MissingImageName,
24    ParseRegistry(registry::FromStrError),
25    MissingRepository,
26}
27
28#[derive(Debug)]
29pub enum FromUrlError {}
30
31#[derive(Debug, PartialEq, Clone, Eq, Hash)]
32pub struct Image {
33    pub registry: Registry,
34    pub namespace: Option<String>,
35    pub repository: Option<String>,
36    pub image_name: ImageName,
37}
38
39impl std::fmt::Display for FromStrError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            Self::MissingFirstComponent => write!(f, "missing first component"),
43            Self::UnsupportedImageName(s) => write!(f, "unsupported image name: {s}"),
44            Self::ParseImageName(err) => write!(f, "{err}"),
45            Self::MissingRegistry => write!(f, "missing registry"),
46            Self::MissingImageName => write!(f, "missing image name"),
47            Self::ParseRegistry(err) => write!(f, "failed to parse registry: {err}"),
48            Self::MissingRepository => write!(f, "missing repository"),
49        }
50    }
51}
52
53impl std::error::Error for FromStrError {}
54
55impl std::fmt::Display for FromUrlError {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.write_str("failed to parse image from URL")
58    }
59}
60
61impl std::error::Error for FromUrlError {}
62
63impl std::str::FromStr for Image {
64    type Err = FromStrError;
65
66    fn from_str(s: &str) -> Result<Self, Self::Err> {
67        let components = s.split('/').collect::<Vec<_>>();
68
69        // alpine
70        // prom/prometheus:v2.53.2
71        // quay.io/openshift-community-operators/external-secrets-operator:v0.9.9
72        // registry.access.redhat.com/ubi8:8.9
73        match components.as_slice() {
74            [] => Err(FromStrError::MissingFirstComponent),
75
76            // Case where only a docker image name is provided without a registry we default to
77            // DockerHub as a registry and the library repository
78            [image_name] => {
79                let image_name = image_name.parse().map_err(Self::Err::ParseImageName)?;
80
81                Ok(Image {
82                    registry: Registry::DockerHub,
83                    namespace: None,
84                    repository: Some("library".to_string()),
85                    image_name,
86                })
87            }
88
89            // Case where we have a registry and a docker image name without a repository, or a
90            // registry without a repository and a docker image name
91            [registry_or_repository, image_name] => {
92                let result = registry_or_repository.parse();
93
94                if let Ok(registry) = result {
95                    let image_name = image_name.parse().map_err(Self::Err::ParseImageName)?;
96
97                    Ok(Image {
98                        registry,
99                        namespace: None,
100                        repository: None,
101                        image_name,
102                    })
103                } else {
104                    // Case where we have a repository and a docker image name as the registry
105                    // could not be parsed
106                    let repository = (*registry_or_repository).to_string();
107                    let image_name = image_name.parse().map_err(Self::Err::ParseImageName)?;
108
109                    Ok(Image {
110                        registry: Registry::DockerHub,
111                        namespace: None,
112                        repository: Some(repository),
113                        image_name,
114                    })
115                }
116            }
117
118            // Case where we have a registry, a repository and a docker image name
119            [registry, repository, image_name] => {
120                let registry = registry.parse().map_err(Self::Err::ParseRegistry)?;
121                let repository = (*repository).to_string();
122                let image_name = image_name.parse().map_err(Self::Err::ParseImageName)?;
123
124                Ok(Image {
125                    registry,
126                    namespace: None,
127                    repository: Some(repository),
128                    image_name,
129                })
130            }
131
132            // Case where we have a registry, a repository and a docker image name and a namespace
133            [registry, namespace, repository, image_name] => {
134                let registry = registry.parse().map_err(Self::Err::ParseRegistry)?;
135                let namespace = (*namespace).to_string();
136                let repository = (*repository).to_string();
137                let image_name = image_name.parse().map_err(Self::Err::ParseImageName)?;
138
139                Ok(Image {
140                    registry,
141                    namespace: Some(namespace),
142                    repository: Some(repository),
143                    image_name,
144                })
145            }
146
147            // Other cases are not supported
148            _ => {
149                let err = Self::Err::UnsupportedImageName(s.to_string());
150                error!("{err}");
151
152                Err(err)
153            }
154        }
155    }
156}
157
158impl std::fmt::Display for Image {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        write!(
161            f,
162            "{registry}/{namespace}{repository}{image_name}",
163            registry = self.registry.registry_domain(),
164            namespace = match self.namespace {
165                Some(ref namespace) => format!("{namespace}/"),
166                None => String::new(),
167            },
168            repository = match self.repository {
169                Some(ref repository) => format!("{repository}/"),
170                None => String::new(),
171            },
172            image_name = self.image_name
173        )
174    }
175}
176
177impl Serialize for Image {
178    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
179    where
180        S: serde::Serializer,
181    {
182        serde::Serialize::serialize(&self.to_string(), serializer)
183    }
184}
185
186impl<'de> Deserialize<'de> for Image {
187    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
188    where
189        D: serde::Deserializer<'de>,
190    {
191        let string = String::deserialize(deserializer)?;
192        let image_name = string.parse().map_err(serde::de::Error::custom)?;
193
194        Ok(image_name)
195    }
196}
197
198#[cfg(test)]
199#[expect(clippy::unwrap_used, reason = "using unwrap in tests is fine")]
200mod tests {
201    mod from_str {
202        use either::Either;
203        use pretty_assertions::assert_eq;
204
205        use crate::{
206            Image,
207            ImageName,
208            Registry,
209            Tag,
210        };
211
212        #[test]
213        fn full_tag() {
214            let expected = Image {
215                registry: Registry::Github,
216                namespace: None,
217                repository: Some("aquasecurity".to_string()),
218                image_name: ImageName {
219                    name: "trivy".to_string(),
220                    identifier: Either::Left(Tag::Specific("0.52.0".to_string())),
221                },
222            };
223
224            let got = "ghcr.io/aquasecurity/trivy:0.52.0"
225                .parse::<Image>()
226                .unwrap();
227
228            assert_eq!(expected, got);
229
230            let expected = Image {
231                registry: Registry::Quay,
232                namespace: None,
233                repository: Some("openshift-community-operators".to_string()),
234                image_name: ImageName {
235                    name: "external-secrets-operator".to_string(),
236                    identifier: Either::Left(Tag::Specific("v0.9.9".to_string())),
237                },
238            };
239
240            let got = "quay.io/openshift-community-operators/external-secrets-operator:v0.9.9"
241                .parse::<Image>()
242                .unwrap();
243
244            assert_eq!(expected, got);
245        }
246
247        #[test]
248        fn just_name() {
249            let expected = Image {
250                registry: Registry::DockerHub,
251                namespace: None,
252                repository: Some("library".to_string()),
253                image_name: ImageName {
254                    name: "archlinux".to_string(),
255                    identifier: Either::Left(Tag::Latest),
256                },
257            };
258
259            let got = "archlinux:latest".parse::<Image>().unwrap();
260
261            assert_eq!(expected, got);
262        }
263
264        #[test]
265        fn digest() {
266            let expected = Image {
267                registry: Registry::Quay,
268                namespace: None,
269                repository: Some("openshift-community-operators".to_string()),
270                image_name: ImageName {
271                    name: "external-secrets-operator".to_string(),
272                    identifier: Either::Right(
273                        "sha256:2247f14d217577b451727b3015f95e97d47941e96b99806f8589a34c43112ec3"
274                            .parse()
275                            .unwrap(),
276                    ),
277                },
278            };
279
280            let got = "quay.io/openshift-community-operators/external-secrets-operator@sha256:\
281                       2247f14d217577b451727b3015f95e97d47941e96b99806f8589a34c43112ec3"
282                .parse::<Image>()
283                .unwrap();
284
285            assert_eq!(expected, got);
286        }
287
288        mod dockerhub {
289            use either::Either;
290            use pretty_assertions::assert_eq;
291
292            use crate::{
293                Image,
294                ImageName,
295                Registry,
296                Tag,
297            };
298
299            #[test]
300            fn prometheus() {
301                const INPUT: &str = "prom/prometheus:v2.53.2";
302
303                let expected = Image {
304                    registry: Registry::DockerHub,
305                    namespace: None,
306                    repository: Some("prom".to_string()),
307                    image_name: ImageName {
308                        name: "prometheus".to_string(),
309                        identifier: Either::Left(Tag::Specific("v2.53.2".to_string())),
310                    },
311                };
312
313                let got = INPUT.parse::<Image>().unwrap();
314
315                assert_eq!(expected, got);
316            }
317        }
318
319        mod redhat {
320            use either::Either;
321            use pretty_assertions::assert_eq;
322
323            use crate::{
324                Image,
325                ImageName,
326                Registry,
327                Tag,
328            };
329
330            #[test]
331            fn ubi8() {
332                const INPUT: &str = "registry.access.redhat.com/ubi8:8.9";
333
334                let expected = Image {
335                    registry: Registry::RedHat,
336                    namespace: None,
337                    repository: None,
338                    image_name: ImageName {
339                        name: "ubi8".to_string(),
340                        identifier: Either::Left(Tag::Specific("8.9".to_string())),
341                    },
342                };
343
344                let got = INPUT.parse::<Image>().unwrap();
345
346                assert_eq!(expected, got);
347            }
348        }
349
350        mod k8s {
351            use either::Either;
352            use pretty_assertions::assert_eq;
353
354            use crate::{
355                Image,
356                ImageName,
357                Registry,
358                Tag,
359            };
360
361            #[test]
362            fn vpa() {
363                const INPUT: &str = "registry.k8s.io/autoscaling/vpa-recommender:1.1.2";
364
365                let expected = Image {
366                    registry: Registry::K8s,
367                    namespace: None,
368                    repository: Some("autoscaling".to_string()),
369                    image_name: ImageName {
370                        name: "vpa-recommender".to_string(),
371                        identifier: Either::Left(Tag::Specific("1.1.2".to_string())),
372                    },
373                };
374
375                let got = INPUT.parse::<Image>().unwrap();
376
377                assert_eq!(expected, got);
378            }
379        }
380
381        mod github {
382            use either::Either;
383            use pretty_assertions::assert_eq;
384
385            use crate::{
386                Image,
387                ImageName,
388                Registry,
389                Tag,
390            };
391
392            #[test]
393            fn cosign() {
394                const INPUT: &str = "ghcr.io/sigstore/cosign/cosign:v2.4.0";
395
396                let expected = Image {
397                    registry: Registry::Github,
398                    namespace: Some("sigstore".to_string()),
399                    repository: Some("cosign".to_string()),
400                    image_name: ImageName {
401                        name: "cosign".to_string(),
402                        identifier: Either::Left(Tag::Specific("v2.4.0".to_string())),
403                    },
404                };
405
406                let got = INPUT.parse::<Image>().unwrap();
407
408                assert_eq!(expected, got);
409            }
410        }
411    }
412}