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 match components.as_slice() {
74 [] => Err(FromStrError::MissingFirstComponent),
75
76 [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 [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 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 [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 [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 _ => {
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}