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#![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                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}))?$"
119            )
120            .unwrap();
121        }
122
123        if let Some(captures) = DOCKER_IMAGE_REGEX.captures(s) {
124            Ok(DockerImage {
125                registry: captures.name("registry").map(|m| m.as_str().to_string()),
126                name: captures
127                    .name("name")
128                    .ok_or(DockerImageError::InvalidFormat)?
129                    .as_str()
130                    .to_string(),
131                tag: captures.name("tag").map(|m| m.as_str().to_string()),
132                digest: captures.name("digest").map(|m| m.as_str().to_string()),
133            })
134        } else {
135            Err(DockerImageError::InvalidFormat)
136        }
137    }
138}
139
140impl DockerImage {
141    /// Parses a Docker image string into its structured components.
142    ///
143    /// This is a convenience function for [`DockerImage::from_str`].
144    ///
145    /// # Examples
146    /// ```
147    /// use docker_image::DockerImage;
148    ///
149    /// let image = DockerImage::parse("ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2").unwrap();
150    /// assert_eq!(image.name, "ubuntu");
151    /// assert_eq!(image.digest, Some("sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2".to_string()));
152    /// ```
153    pub fn parse(image_str: &str) -> Result<Self, DockerImageError> {
154        Self::from_str(image_str)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use test_format::assert_display_fmt;
162
163    #[test]
164    fn test_trivial_name() {
165        let result = DockerImage::parse("nginx");
166        assert_eq!(
167            result,
168            Ok(DockerImage {
169                registry: None,
170                name: "nginx".to_string(),
171                tag: None,
172                digest: None,
173            })
174        );
175    }
176
177    #[test]
178    fn test_name_with_tag() {
179        let result = DockerImage::parse("nginx:latest");
180        assert_eq!(
181            result,
182            Ok(DockerImage {
183                registry: None,
184                name: "nginx".to_string(),
185                tag: Some("latest".to_string()),
186                digest: None,
187            })
188        );
189    }
190
191    #[test]
192    fn test_name_with_complex_tag() {
193        let result = DockerImage::parse("nginx:stable-alpine3.20-perl");
194        assert_eq!(
195            result,
196            Ok(DockerImage {
197                registry: None,
198                name: "nginx".to_string(),
199                tag: Some("stable-alpine3.20-perl".to_string()),
200                digest: None,
201            })
202        );
203    }
204
205    #[test]
206    fn test_registry_and_name() {
207        let result = DockerImage::parse("docker.io/nginx");
208        assert_eq!(
209            result,
210            Ok(DockerImage {
211                registry: Some("docker.io".to_string()),
212                name: "nginx".to_string(),
213                tag: None,
214                digest: None,
215            })
216        );
217    }
218
219    #[test]
220    fn test_registry_with_namespace() {
221        let result = DockerImage::parse("ghcr.io/nginx/nginx");
222        assert_eq!(
223            result,
224            Ok(DockerImage {
225                registry: Some("ghcr.io".to_string()),
226                name: "nginx/nginx".to_string(),
227                tag: None,
228                digest: None,
229            })
230        );
231    }
232
233    #[test]
234    fn test_name_with_digest() {
235        let result = DockerImage::parse(
236            "ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
237        );
238        assert_eq!(
239            result,
240            Ok(DockerImage {
241                registry: None,
242                name: "ubuntu".to_string(),
243                tag: None,
244                digest: Some(
245                    "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
246                        .to_string()
247                ),
248            })
249        );
250    }
251
252    #[test]
253    fn test_name_with_tag_and_digest() {
254        let result = DockerImage::parse(
255            "ubuntu:latest@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2",
256        );
257        assert_eq!(
258            result,
259            Ok(DockerImage {
260                registry: None,
261                name: "ubuntu".to_string(),
262                tag: Some("latest".to_string()),
263                digest: Some(
264                    "sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2"
265                        .to_string()
266                ),
267            })
268        );
269    }
270
271    #[test]
272    fn test_registry_name_tag() {
273        let result = DockerImage::parse("registry.example.com/library/my-image:1.0.0");
274        assert_eq!(
275            result,
276            Ok(DockerImage {
277                registry: Some("registry.example.com".to_string()),
278                name: "library/my-image".to_string(),
279                tag: Some("1.0.0".to_string()),
280                digest: None,
281            })
282        );
283    }
284
285    #[test]
286    fn test_registry_name_digest() {
287        let result = DockerImage::parse(
288            "my-registry.local:5000/library/image-name@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
289        );
290        assert_eq!(
291            result,
292            Ok(DockerImage {
293                registry: Some("my-registry.local:5000".to_string()),
294                name: "library/image-name".to_string(),
295                tag: None,
296                digest: Some(
297                    "sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234"
298                        .to_string()
299                ),
300            })
301        );
302    }
303
304    #[test]
305    fn test_invalid_format() {
306        let result = DockerImage::parse("invalid@@sha256:wrong");
307        assert_eq!(result, Err(DockerImageError::InvalidFormat));
308    }
309
310    #[test]
311    fn test_invalid_characters_in_tag() {
312        let result = DockerImage::parse("nginx:lat@est");
313        assert_eq!(result, Err(DockerImageError::InvalidFormat));
314    }
315
316    #[test]
317    fn test_invalid_digest_format() {
318        let result = DockerImage::parse("ubuntu@sha256:not-a-hex-string");
319        assert_eq!(result, Err(DockerImageError::InvalidFormat));
320    }
321
322    #[test]
323    fn test_invalid_registry_format() {
324        let result = DockerImage::parse("http://registry.example.com/image-name");
325        assert_eq!(result, Err(DockerImageError::InvalidFormat));
326    }
327
328    #[test]
329    fn test_invalid_double_colons_in_tag() {
330        let result = DockerImage::parse("nginx::latest");
331        assert_eq!(result, Err(DockerImageError::InvalidFormat));
332    }
333
334    #[test]
335    fn test_missing_image_name_with_tag() {
336        let result = DockerImage::parse(":latest");
337        assert_eq!(result, Err(DockerImageError::InvalidFormat));
338    }
339
340    #[test]
341    fn test_missing_image_name_with_digest() {
342        let result = DockerImage::parse(
343            "@sha256:deadbeefcafe1234567890abcdef1234567890abcdef1234567890abcdef1234",
344        );
345        assert_eq!(result, Err(DockerImageError::InvalidFormat));
346    }
347
348    #[test]
349    fn test_extra_tag_components() {
350        let result = DockerImage::parse("my-image:1.0.0:latest");
351        assert_eq!(result, Err(DockerImageError::InvalidFormat));
352    }
353
354    #[test]
355    fn test_unicode_in_name() {
356        let result = DockerImage::parse("nginx🚀");
357        assert_eq!(result, Err(DockerImageError::InvalidFormat));
358    }
359
360    #[test]
361    fn test_unicode_in_registry() {
362        let result = DockerImage::parse("docker🚀.io/library/nginx");
363        assert_eq!(result, Err(DockerImageError::InvalidFormat));
364    }
365
366    #[test]
367    fn test_unicode_in_tag() {
368        let result = DockerImage::parse("nginx:lat🚀est");
369        assert_eq!(result, Err(DockerImageError::InvalidFormat));
370    }
371
372    #[test]
373    fn test_unicode_in_digest() {
374        let result = DockerImage::parse(
375            "nginx@sha256:deadbeef🚀1234567890abcdef1234567890abcdef1234567890abcdef1234",
376        );
377        assert_eq!(result, Err(DockerImageError::InvalidFormat));
378    }
379
380    #[test]
381    fn test_display_trivial_name() {
382        let image = DockerImage {
383            registry: None,
384            name: "nginx".to_string(),
385            tag: None,
386            digest: None,
387        };
388
389        assert_display_fmt!(image, "nginx");
390    }
391
392    #[test]
393    fn test_display_name_with_tag() {
394        let image = DockerImage {
395            registry: None,
396            name: "nginx".to_string(),
397            tag: Some("latest".to_string()),
398            digest: None,
399        };
400
401        assert_display_fmt!(image, "nginx:latest");
402    }
403
404    #[test]
405    fn test_display_name_with_digest() {
406        let image = DockerImage {
407            registry: None,
408            name: "ubuntu".to_string(),
409            tag: None,
410            digest: Some(
411                "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
412            ),
413        };
414
415        assert_display_fmt!(
416            image,
417            "ubuntu@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
418        );
419    }
420
421    #[test]
422    fn test_display_name_with_tag_and_digest() {
423        let image = DockerImage {
424            registry: None,
425            name: "ubuntu".to_string(),
426            tag: Some("latest".to_string()),
427            digest: Some(
428                "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
429            ),
430        };
431
432        assert_display_fmt!(
433            image,
434            "ubuntu:latest@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
435        );
436    }
437
438    #[test]
439    fn test_display_registry_and_name() {
440        let image = DockerImage {
441            registry: Some("docker.io".to_string()),
442            name: "library/nginx".to_string(),
443            tag: None,
444            digest: None,
445        };
446
447        assert_display_fmt!(image, "docker.io/library/nginx");
448    }
449
450    #[test]
451    fn test_display_registry_name_with_tag() {
452        let image = DockerImage {
453            registry: Some("docker.io".to_string()),
454            name: "library/nginx".to_string(),
455            tag: Some("latest".to_string()),
456            digest: None,
457        };
458
459        assert_display_fmt!(image, "docker.io/library/nginx:latest");
460    }
461
462    #[test]
463    fn test_display_full_reference() {
464        let image = DockerImage {
465            registry: Some("my-registry.local:5000".to_string()),
466            name: "library/image-name".to_string(),
467            tag: Some("v1.0.0".to_string()),
468            digest: Some(
469                "sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234".to_string(),
470            ),
471        };
472
473        assert_display_fmt!(
474            image,
475            "my-registry.local:5000/library/image-name:v1.0.0@sha256:deadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234"
476        );
477    }
478}