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 TryFrom<String> for DockerImage {
142    type Error = DockerImageError;
143
144    fn try_from(value: String) -> Result<Self, Self::Error> {
145        value.parse()
146    }
147}
148
149impl TryFrom<&str> for DockerImage {
150    type Error = DockerImageError;
151
152    fn try_from(value: &str) -> Result<Self, Self::Error> {
153        value.parse()
154    }
155}
156
157impl DockerImage {
158    /// Parses a Docker image string into its structured components.
159    ///
160    /// This is a convenience function for [`DockerImage::from_str`].
161    ///
162    /// # Examples
163    /// ```
164    /// use docker_image::DockerImage;
165    ///
166    /// let image = DockerImage::parse("ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2").unwrap();
167    /// assert_eq!(image.name, "ubuntu");
168    /// assert_eq!(image.digest, Some("sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2".to_string()));
169    /// ```
170    pub fn parse(image_str: &str) -> Result<Self, DockerImageError> {
171        Self::from_str(image_str)
172    }
173}
174
175#[cfg(feature = "serde-serialize")]
176impl serde::Serialize for DockerImage {
177    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178    where
179        S: serde::ser::Serializer,
180    {
181        serializer.serialize_str(&self.to_string())
182    }
183}
184
185#[cfg(feature = "serde-deserialize")]
186impl<'de> serde::Deserialize<'de> for DockerImage {
187    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
188    where
189        D: serde::de::Deserializer<'de>,
190    {
191        let docker_image_str = <String as serde::Deserialize>::deserialize(deserializer)?;
192        docker_image_str.parse().map_err(serde::de::Error::custom)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use test_format::assert_display_fmt;
200
201    #[test]
202    fn test_trivial_name() {
203        let result = DockerImage::parse("nginx");
204        assert_eq!(
205            result,
206            Ok(DockerImage {
207                registry: None,
208                name: "nginx".to_string(),
209                tag: None,
210                digest: None,
211            })
212        );
213    }
214
215    #[test]
216    fn test_name_with_tag() {
217        let result = DockerImage::parse("nginx:latest");
218        assert_eq!(
219            result,
220            Ok(DockerImage {
221                registry: None,
222                name: "nginx".to_string(),
223                tag: Some("latest".to_string()),
224                digest: None,
225            })
226        );
227    }
228
229    #[test]
230    fn test_name_with_complex_tag() {
231        let result = DockerImage::parse("nginx:stable-alpine3.20-perl");
232        assert_eq!(
233            result,
234            Ok(DockerImage {
235                registry: None,
236                name: "nginx".to_string(),
237                tag: Some("stable-alpine3.20-perl".to_string()),
238                digest: None,
239            })
240        );
241    }
242
243    #[test]
244    fn test_registry_and_name() {
245        let result = DockerImage::parse("docker.io/nginx");
246        assert_eq!(
247            result,
248            Ok(DockerImage {
249                registry: Some("docker.io".to_string()),
250                name: "nginx".to_string(),
251                tag: None,
252                digest: None,
253            })
254        );
255    }
256
257    #[test]
258    fn test_registry_with_namespace() {
259        let result = DockerImage::parse("ghcr.io/nginx/nginx");
260        assert_eq!(
261            result,
262            Ok(DockerImage {
263                registry: Some("ghcr.io".to_string()),
264                name: "nginx/nginx".to_string(),
265                tag: None,
266                digest: None,
267            })
268        );
269    }
270
271    #[test]
272    fn test_name_with_digest() {
273        let result = DockerImage::parse(
274            "ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
275        );
276        assert_eq!(
277            result,
278            Ok(DockerImage {
279                registry: None,
280                name: "ubuntu".to_string(),
281                tag: None,
282                digest: Some(
283                    "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
284                        .to_string()
285                ),
286            })
287        );
288    }
289
290    #[test]
291    fn test_name_with_tag_and_digest() {
292        let result = DockerImage::parse(
293            "ubuntu:latest@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
294        );
295        assert_eq!(
296            result,
297            Ok(DockerImage {
298                registry: None,
299                name: "ubuntu".to_string(),
300                tag: Some("latest".to_string()),
301                digest: Some(
302                    "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
303                        .to_string()
304                ),
305            })
306        );
307    }
308
309    #[test]
310    fn test_registry_name_tag() {
311        let result = DockerImage::parse("registry.example.com/library/my-image:1.0.0");
312        assert_eq!(
313            result,
314            Ok(DockerImage {
315                registry: Some("registry.example.com".to_string()),
316                name: "library/my-image".to_string(),
317                tag: Some("1.0.0".to_string()),
318                digest: None,
319            })
320        );
321    }
322
323    #[test]
324    fn test_registry_name_digest() {
325        let result = DockerImage::parse(
326            "my-registry.local:5000/library/image-name@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
327        );
328        assert_eq!(
329            result,
330            Ok(DockerImage {
331                registry: Some("my-registry.local:5000".to_string()),
332                name: "library/image-name".to_string(),
333                tag: None,
334                digest: Some(
335                    "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
336                        .to_string()
337                ),
338            })
339        );
340    }
341
342    #[test]
343    fn test_invalid_format() {
344        let result = DockerImage::parse("invalid@@sha256:wrong");
345        assert_eq!(result, Err(DockerImageError::InvalidFormat));
346    }
347
348    #[test]
349    fn test_invalid_characters_in_tag() {
350        let result = DockerImage::parse("nginx:lat@est");
351        assert_eq!(result, Err(DockerImageError::InvalidFormat));
352    }
353
354    #[test]
355    fn test_invalid_digest_format() {
356        let result = DockerImage::parse("ubuntu@sha256:not-a-hex-string");
357        assert_eq!(result, Err(DockerImageError::InvalidFormat));
358    }
359
360    #[test]
361    fn test_invalid_registry_format() {
362        let result = DockerImage::parse("http://registry.example.com/image-name");
363        assert_eq!(result, Err(DockerImageError::InvalidFormat));
364    }
365
366    #[test]
367    fn test_invalid_double_colons_in_tag() {
368        let result = DockerImage::parse("nginx::latest");
369        assert_eq!(result, Err(DockerImageError::InvalidFormat));
370    }
371
372    #[test]
373    fn test_missing_image_name_with_tag() {
374        let result = DockerImage::parse(":latest");
375        assert_eq!(result, Err(DockerImageError::InvalidFormat));
376    }
377
378    #[test]
379    fn test_missing_image_name_with_digest() {
380        let result = DockerImage::parse(
381            "@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
382        );
383        assert_eq!(result, Err(DockerImageError::InvalidFormat));
384    }
385
386    #[test]
387    fn test_extra_tag_components() {
388        let result = DockerImage::parse("my-image:1.0.0:latest");
389        assert_eq!(result, Err(DockerImageError::InvalidFormat));
390    }
391
392    #[test]
393    fn test_unicode_in_name() {
394        let result = DockerImage::parse("nginx🚀");
395        assert_eq!(result, Err(DockerImageError::InvalidFormat));
396    }
397
398    #[test]
399    fn test_unicode_in_registry() {
400        let result = DockerImage::parse("docker🚀.io/library/nginx");
401        assert_eq!(result, Err(DockerImageError::InvalidFormat));
402    }
403
404    #[test]
405    fn test_unicode_in_tag() {
406        let result = DockerImage::parse("nginx:lat🚀est");
407        assert_eq!(result, Err(DockerImageError::InvalidFormat));
408    }
409
410    #[test]
411    fn test_unicode_in_digest() {
412        let result = DockerImage::parse(
413            "nginx@sha256:deadbeef🚀1234567890abcdef1234567890abcdef1234567890abcdef1234",
414        );
415        assert_eq!(result, Err(DockerImageError::InvalidFormat));
416    }
417
418    #[test]
419    fn test_display_trivial_name() {
420        let image = DockerImage {
421            registry: None,
422            name: "nginx".to_string(),
423            tag: None,
424            digest: None,
425        };
426
427        assert_display_fmt!(image, "nginx");
428    }
429
430    #[test]
431    fn test_display_name_with_tag() {
432        let image = DockerImage {
433            registry: None,
434            name: "nginx".to_string(),
435            tag: Some("latest".to_string()),
436            digest: None,
437        };
438
439        assert_display_fmt!(image, "nginx:latest");
440    }
441
442    #[test]
443    fn test_display_name_with_digest() {
444        let image = DockerImage {
445            registry: None,
446            name: "ubuntu".to_string(),
447            tag: None,
448            digest: Some(
449                "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
450            ),
451        };
452
453        assert_display_fmt!(
454            image,
455            "ubuntu@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
456        );
457    }
458
459    #[test]
460    fn test_display_name_with_tag_and_digest() {
461        let image = DockerImage {
462            registry: None,
463            name: "ubuntu".to_string(),
464            tag: Some("latest".to_string()),
465            digest: Some(
466                "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
467            ),
468        };
469
470        assert_display_fmt!(
471            image,
472            "ubuntu:latest@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
473        );
474    }
475
476    #[test]
477    fn test_display_registry_and_name() {
478        let image = DockerImage {
479            registry: Some("docker.io".to_string()),
480            name: "library/nginx".to_string(),
481            tag: None,
482            digest: None,
483        };
484
485        assert_display_fmt!(image, "docker.io/library/nginx");
486    }
487
488    #[test]
489    fn test_display_registry_name_with_tag() {
490        let image = DockerImage {
491            registry: Some("docker.io".to_string()),
492            name: "library/nginx".to_string(),
493            tag: Some("latest".to_string()),
494            digest: None,
495        };
496
497        assert_display_fmt!(image, "docker.io/library/nginx:latest");
498    }
499
500    #[test]
501    fn test_display_full_reference() {
502        let image = DockerImage {
503            registry: Some("my-registry.local:5000".to_string()),
504            name: "library/image-name".to_string(),
505            tag: Some("v1.0.0".to_string()),
506            digest: Some(
507                "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
508            ),
509        };
510
511        assert_display_fmt!(
512            image,
513            "my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
514        );
515    }
516
517    #[test]
518    #[cfg(feature = "serde-serialize")]
519    fn test_serialize_dockerimage_to_json() {
520        use serde_json;
521
522        let image = DockerImage {
523            registry: Some("my-registry.local:5000".to_string()),
524            name: "library/image-name".to_string(),
525            tag: Some("v1.0.0".to_string()),
526            digest: Some(
527                "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
528                    .to_string(),
529            ),
530        };
531
532        let serialized = serde_json::to_string(&image).expect("Failed to serialize DockerImage");
533        assert_eq!(
534            serialized,
535            r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#
536        );
537    }
538
539    #[test]
540    #[cfg(feature = "serde-deserialize")]
541    fn test_deserialize_dockerimage_from_json() {
542        use serde_json;
543
544        let json = r#""my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234""#;
545
546        let image: DockerImage =
547            serde_json::from_str(json).expect("Failed to deserialize DockerImage");
548        assert_eq!(
549            image,
550            DockerImage {
551                registry: Some("my-registry.local:5000".to_string()),
552                name: "library/image-name".to_string(),
553                tag: Some("v1.0.0".to_string()),
554                digest: Some(
555                    "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
556                        .to_string()
557                ),
558            }
559        );
560    }
561}