1#![cfg_attr(not(feature = "serde-deserialize"), no_std)]
9#![forbid(unsafe_code)]
10
11extern crate alloc;
12use alloc::string::{String, ToString};
13
14use core::fmt;
15use core::str::FromStr;
16use lazy_static::lazy_static;
17use regex::Regex;
18
19#[derive(Debug, PartialEq)]
38pub struct DockerImage {
39 pub registry: Option<String>,
41 pub name: String,
43 pub tag: Option<String>,
45 pub digest: Option<String>,
47}
48
49impl fmt::Display for DockerImage {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 if let Some(registry) = &self.registry {
63 write!(f, "{}/", registry)?;
64 }
65 write!(f, "{}", self.name)?;
66 if let Some(tag) = &self.tag {
67 write!(f, ":{}", tag)?;
68 }
69 if let Some(digest) = &self.digest {
70 write!(f, "@{}", digest)?;
71 }
72 Ok(())
73 }
74}
75
76#[derive(Debug, PartialEq)]
78pub enum DockerImageError {
79 InvalidFormat,
81}
82
83impl fmt::Display for DockerImageError {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
86 DockerImageError::InvalidFormat => write!(f, "Invalid Docker image format"),
87 }
88 }
89}
90
91impl core::error::Error for DockerImageError {}
92
93impl FromStr for DockerImage {
94 type Err = DockerImageError;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
116 lazy_static! {
117 static ref DOCKER_IMAGE_REGEX: Regex = Regex::new(
118 r"^(?:(?P<registry>[a-z0-9]+(?:[._-][a-z0-9]+)*\.[a-z]{2,}(?::\d+)?)/)?(?P<name>[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*)(?::(?P<tag>[a-zA-Z0-9._-]+))?(?:@(?P<digest>[a-z0-9]+:[a-fA-F0-9]{64}))?$"
120 )
121 .expect("Invalid regular expression for Docker image format");
122 }
123
124 if let Some(captures) = DOCKER_IMAGE_REGEX.captures(s) {
125 Ok(DockerImage {
126 registry: captures.name("registry").map(|m| m.as_str().to_string()),
127 name: captures
128 .name("name")
129 .ok_or(DockerImageError::InvalidFormat)?
130 .as_str()
131 .to_string(),
132 tag: captures.name("tag").map(|m| m.as_str().to_string()),
133 digest: captures.name("digest").map(|m| m.as_str().to_string()),
134 })
135 } else {
136 Err(DockerImageError::InvalidFormat)
137 }
138 }
139}
140
141impl DockerImage {
142 pub fn parse(image_str: &str) -> Result<Self, DockerImageError> {
155 Self::from_str(image_str)
156 }
157}
158
159#[cfg(feature = "serde-serialize")]
160impl serde::Serialize for DockerImage {
161 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
162 where
163 S: serde::ser::Serializer
164 {
165 serializer.serialize_str(&self.to_string())
166 }
167}
168
169#[cfg(feature = "serde-deserialize")]
170impl<'de> serde::Deserialize<'de> for DockerImage {
171 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172 where
173 D: serde::de::Deserializer<'de>
174 {
175 let docker_image_str = <String as serde::Deserialize>::deserialize(deserializer)?;
176 docker_image_str
177 .parse()
178 .map_err(serde::de::Error::custom)
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use test_format::assert_display_fmt;
186
187 #[test]
188 fn test_trivial_name() {
189 let result = DockerImage::parse("nginx");
190 assert_eq!(
191 result,
192 Ok(DockerImage {
193 registry: None,
194 name: "nginx".to_string(),
195 tag: None,
196 digest: None,
197 })
198 );
199 }
200
201 #[test]
202 fn test_name_with_tag() {
203 let result = DockerImage::parse("nginx:latest");
204 assert_eq!(
205 result,
206 Ok(DockerImage {
207 registry: None,
208 name: "nginx".to_string(),
209 tag: Some("latest".to_string()),
210 digest: None,
211 })
212 );
213 }
214
215 #[test]
216 fn test_name_with_complex_tag() {
217 let result = DockerImage::parse("nginx:stable-alpine3.20-perl");
218 assert_eq!(
219 result,
220 Ok(DockerImage {
221 registry: None,
222 name: "nginx".to_string(),
223 tag: Some("stable-alpine3.20-perl".to_string()),
224 digest: None,
225 })
226 );
227 }
228
229 #[test]
230 fn test_registry_and_name() {
231 let result = DockerImage::parse("docker.io/nginx");
232 assert_eq!(
233 result,
234 Ok(DockerImage {
235 registry: Some("docker.io".to_string()),
236 name: "nginx".to_string(),
237 tag: None,
238 digest: None,
239 })
240 );
241 }
242
243 #[test]
244 fn test_registry_with_namespace() {
245 let result = DockerImage::parse("ghcr.io/nginx/nginx");
246 assert_eq!(
247 result,
248 Ok(DockerImage {
249 registry: Some("ghcr.io".to_string()),
250 name: "nginx/nginx".to_string(),
251 tag: None,
252 digest: None,
253 })
254 );
255 }
256
257 #[test]
258 fn test_name_with_digest() {
259 let result = DockerImage::parse(
260 "ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
261 );
262 assert_eq!(
263 result,
264 Ok(DockerImage {
265 registry: None,
266 name: "ubuntu".to_string(),
267 tag: None,
268 digest: Some(
269 "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
270 .to_string()
271 ),
272 })
273 );
274 }
275
276 #[test]
277 fn test_name_with_tag_and_digest() {
278 let result = DockerImage::parse(
279 "ubuntu:latest@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
280 );
281 assert_eq!(
282 result,
283 Ok(DockerImage {
284 registry: None,
285 name: "ubuntu".to_string(),
286 tag: Some("latest".to_string()),
287 digest: Some(
288 "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
289 .to_string()
290 ),
291 })
292 );
293 }
294
295 #[test]
296 fn test_registry_name_tag() {
297 let result = DockerImage::parse("registry.example.com/library/my-image:1.0.0");
298 assert_eq!(
299 result,
300 Ok(DockerImage {
301 registry: Some("registry.example.com".to_string()),
302 name: "library/my-image".to_string(),
303 tag: Some("1.0.0".to_string()),
304 digest: None,
305 })
306 );
307 }
308
309 #[test]
310 fn test_registry_name_digest() {
311 let result = DockerImage::parse(
312 "my-registry.local:5000/library/image-name@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
313 );
314 assert_eq!(
315 result,
316 Ok(DockerImage {
317 registry: Some("my-registry.local:5000".to_string()),
318 name: "library/image-name".to_string(),
319 tag: None,
320 digest: Some(
321 "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
322 .to_string()
323 ),
324 })
325 );
326 }
327
328 #[test]
329 fn test_invalid_format() {
330 let result = DockerImage::parse("invalid@@sha256:wrong");
331 assert_eq!(result, Err(DockerImageError::InvalidFormat));
332 }
333
334 #[test]
335 fn test_invalid_characters_in_tag() {
336 let result = DockerImage::parse("nginx:lat@est");
337 assert_eq!(result, Err(DockerImageError::InvalidFormat));
338 }
339
340 #[test]
341 fn test_invalid_digest_format() {
342 let result = DockerImage::parse("ubuntu@sha256:not-a-hex-string");
343 assert_eq!(result, Err(DockerImageError::InvalidFormat));
344 }
345
346 #[test]
347 fn test_invalid_registry_format() {
348 let result = DockerImage::parse("http://registry.example.com/image-name");
349 assert_eq!(result, Err(DockerImageError::InvalidFormat));
350 }
351
352 #[test]
353 fn test_invalid_double_colons_in_tag() {
354 let result = DockerImage::parse("nginx::latest");
355 assert_eq!(result, Err(DockerImageError::InvalidFormat));
356 }
357
358 #[test]
359 fn test_missing_image_name_with_tag() {
360 let result = DockerImage::parse(":latest");
361 assert_eq!(result, Err(DockerImageError::InvalidFormat));
362 }
363
364 #[test]
365 fn test_missing_image_name_with_digest() {
366 let result = DockerImage::parse(
367 "@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
368 );
369 assert_eq!(result, Err(DockerImageError::InvalidFormat));
370 }
371
372 #[test]
373 fn test_extra_tag_components() {
374 let result = DockerImage::parse("my-image:1.0.0:latest");
375 assert_eq!(result, Err(DockerImageError::InvalidFormat));
376 }
377
378 #[test]
379 fn test_unicode_in_name() {
380 let result = DockerImage::parse("nginx🚀");
381 assert_eq!(result, Err(DockerImageError::InvalidFormat));
382 }
383
384 #[test]
385 fn test_unicode_in_registry() {
386 let result = DockerImage::parse("docker🚀.io/library/nginx");
387 assert_eq!(result, Err(DockerImageError::InvalidFormat));
388 }
389
390 #[test]
391 fn test_unicode_in_tag() {
392 let result = DockerImage::parse("nginx:lat🚀est");
393 assert_eq!(result, Err(DockerImageError::InvalidFormat));
394 }
395
396 #[test]
397 fn test_unicode_in_digest() {
398 let result = DockerImage::parse(
399 "nginx@sha256:deadbeef🚀1234567890abcdef1234567890abcdef1234567890abcdef1234",
400 );
401 assert_eq!(result, Err(DockerImageError::InvalidFormat));
402 }
403
404 #[test]
405 fn test_display_trivial_name() {
406 let image = DockerImage {
407 registry: None,
408 name: "nginx".to_string(),
409 tag: None,
410 digest: None,
411 };
412
413 assert_display_fmt!(image, "nginx");
414 }
415
416 #[test]
417 fn test_display_name_with_tag() {
418 let image = DockerImage {
419 registry: None,
420 name: "nginx".to_string(),
421 tag: Some("latest".to_string()),
422 digest: None,
423 };
424
425 assert_display_fmt!(image, "nginx:latest");
426 }
427
428 #[test]
429 fn test_display_name_with_digest() {
430 let image = DockerImage {
431 registry: None,
432 name: "ubuntu".to_string(),
433 tag: None,
434 digest: Some(
435 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
436 ),
437 };
438
439 assert_display_fmt!(
440 image,
441 "ubuntu@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
442 );
443 }
444
445 #[test]
446 fn test_display_name_with_tag_and_digest() {
447 let image = DockerImage {
448 registry: None,
449 name: "ubuntu".to_string(),
450 tag: Some("latest".to_string()),
451 digest: Some(
452 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
453 ),
454 };
455
456 assert_display_fmt!(
457 image,
458 "ubuntu:latest@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
459 );
460 }
461
462 #[test]
463 fn test_display_registry_and_name() {
464 let image = DockerImage {
465 registry: Some("docker.io".to_string()),
466 name: "library/nginx".to_string(),
467 tag: None,
468 digest: None,
469 };
470
471 assert_display_fmt!(image, "docker.io/library/nginx");
472 }
473
474 #[test]
475 fn test_display_registry_name_with_tag() {
476 let image = DockerImage {
477 registry: Some("docker.io".to_string()),
478 name: "library/nginx".to_string(),
479 tag: Some("latest".to_string()),
480 digest: None,
481 };
482
483 assert_display_fmt!(image, "docker.io/library/nginx:latest");
484 }
485
486 #[test]
487 fn test_display_full_reference() {
488 let image = DockerImage {
489 registry: Some("my-registry.local:5000".to_string()),
490 name: "library/image-name".to_string(),
491 tag: Some("v1.0.0".to_string()),
492 digest: Some(
493 "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
494 ),
495 };
496
497 assert_display_fmt!(
498 image,
499 "my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
500 );
501 }
502
503
504 #[test]
505 #[cfg(feature = "serde-serialize")]
506 fn test_serialize_dockerimage_to_json() {
507 use serde_json;
508
509 let image = DockerImage {
510 registry: Some("my-registry.local:5000".to_string()),
511 name: "library/image-name".to_string(),
512 tag: Some("v1.0.0".to_string()),
513 digest: Some(
514 "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
515 ),
516 };
517
518 let serialized = serde_json::to_string(&image).expect("Failed to serialize DockerImage");
519 assert_eq!(
520 serialized,
521 r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#
522 );
523 }
524
525 #[test]
526 #[cfg(feature = "serde-deserialize")]
527 fn test_deserialize_dockerimage_from_json() {
528 use serde_json;
529
530 let json = r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#;
531
532 let image: DockerImage =
533 serde_json::from_str(json).expect("Failed to deserialize DockerImage");
534 assert_eq!(
535 image,
536 DockerImage {
537 registry: Some("my-registry.local:5000".to_string()),
538 name: "library/image-name".to_string(),
539 tag: Some("v1.0.0".to_string()),
540 digest: Some(
541 "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string()
542 ),
543 }
544 );
545 }
546}