docker_image/
lib.rs

1//! # 🐋 docker-image
2//!
3//! A library for parsing and handling Docker image references in a structured way.
4//!
5//! Docker image references can include components like a registry, name, tag, and digest.
6//! This library parses valid Docker image strings into their respective components, with proper validation.
7
8#![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/// Represents a parsed Docker image reference.
20///
21/// A Docker image can have the following components:
22/// - `registry`: The optional registry URL (e.g., `docker.io`, `ghcr.io`, or a custom registry like `my-registry.local:5000`).
23/// - `name`: The mandatory name of the image, which may include namespaces (e.g., `library/nginx`).
24/// - `tag`: An optional version tag for the image (e.g., `latest`, `v1.0.0`).
25/// - `digest`: An optional digest for the image content (e.g., `sha256:<64-hex-digest>`).
26///
27/// # Examples
28/// ```
29/// use docker_image::DockerImage;
30///
31/// let image = DockerImage::parse("docker.io/library/nginx:latest").unwrap();
32/// assert_eq!(image.registry, Some("docker.io".to_string()));
33/// assert_eq!(image.name, "library/nginx".to_string());
34/// assert_eq!(image.tag, Some("latest".to_string()));
35/// assert_eq!(image.digest, None);
36/// ```
37#[derive(Debug, PartialEq)]
38pub struct DockerImage {
39    /// The optional registry URL.
40    pub registry: Option<String>,
41    /// The name of the image, including namespaces if present.
42    pub name: String,
43    /// The optional version tag.
44    pub tag: Option<String>,
45    /// The optional content digest (e.g., `sha256:<64-hex-digest>`).
46    pub digest: Option<String>,
47}
48
49impl fmt::Display for DockerImage {
50    /// Formats the `DockerImage` as a valid Docker image reference string.
51    ///
52    /// The format includes:
53    /// - `[registry/]name[:tag][@digest]`
54    ///
55    /// Examples:
56    /// - `nginx`
57    /// - `nginx:latest`
58    /// - `docker.io/library/nginx:latest`
59    /// - `ubuntu@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234`
60    /// - `my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234`
61    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/// Errors that can occur while parsing Docker image references.
77#[derive(Debug, PartialEq)]
78pub enum DockerImageError {
79    /// Indicates that the Docker image string has an invalid format.
80    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    /// Parses a Docker image string into its structured components.
97    ///
98    /// This function supports the following Docker image formats:
99    /// - `nginx`
100    /// - `nginx:latest`
101    /// - `docker.io/library/nginx`
102    /// - `docker.io/library/nginx:latest`
103    /// - `docker.io/library/nginx@sha256:<digest>`
104    /// - `docker.io/library/nginx:latest@sha256:<digest>`
105    ///
106    /// # Examples
107    /// ```
108    /// use docker_image::DockerImage;
109    ///
110    /// let image: DockerImage = "nginx:latest".parse().unwrap();
111    /// assert_eq!(image.name, "nginx");
112    /// assert_eq!(image.tag, Some("latest".to_string()));
113    /// assert_eq!(image.digest, None);
114    /// ```
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        lazy_static! {
117            static ref DOCKER_IMAGE_REGEX: Regex = Regex::new(
118                // language=regexp
119                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    /// Parses a Docker image string into its structured components.
143    ///
144    /// This is a convenience function for [`DockerImage::from_str`].
145    ///
146    /// # Examples
147    /// ```
148    /// use docker_image::DockerImage;
149    ///
150    /// let image = DockerImage::parse("ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2").unwrap();
151    /// assert_eq!(image.name, "ubuntu");
152    /// assert_eq!(image.digest, Some("sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2".to_string()));
153    /// ```
154    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}