diff --git a/Cargo.lock b/Cargo.lock index 8f54b2d..f5c2825 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "compose_spec" -version = "0.2.1-alpha.2" +version = "0.2.1-alpha.3" dependencies = [ "compose_spec_macros", "indexmap", diff --git a/Cargo.toml b/Cargo.toml index 0c6c4db..91cf2ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -226,7 +226,7 @@ sort_commits = "oldest" [package] name = "compose_spec" -version = "0.2.1-alpha.2" +version = "0.2.1-alpha.3" authors.workspace = true edition.workspace = true license.workspace = true diff --git a/src/service/image.rs b/src/service/image.rs index 8621004..25588b4 100644 --- a/src/service/image.rs +++ b/src/service/image.rs @@ -89,19 +89,19 @@ impl Image { fn parse_impl( image: &str, ) -> Result<(Option, Option), InvalidImageError> { - let (image, digest_start) = if let Some((image, digest)) = image.split_once('@') { - Digest::new(digest)?; - (image, Some(image.len() + 1)) - } else { - (image, None) - }; - - let (image, tag_start) = if let Some((image, tag)) = image.split_once(':') { - Tag::new(tag)?; - (image, Some(image.len() + 1)) - } else { - (image, None) - }; + let (image, digest_start) = image + .split_once('@') + .map_or(Ok((image, None)), |(image, digest)| { + Digest::new(digest).map(|_| (image, Some(image.len() + 1))) + })?; + + let (image, tag_start) = image + .rsplit_once(':') + // If tag contains '/', then image has a registry with a port and no tag. + .filter(|(_, tag)| !tag.contains('/')) + .map_or(Ok((image, None)), |(image, tag)| { + Tag::new(tag).map(|_| (image, Some(image.len() + 1))) + })?; let tag_or_digest = match (digest_start, tag_start) { (None, None) => None, @@ -713,7 +713,6 @@ const fn char_is_alnum(char: char) -> bool { } #[cfg(test)] -#[allow(clippy::unwrap_used)] mod tests { use super::*; @@ -732,8 +731,8 @@ mod tests { } #[test] - fn registry() { - let mut image = Image::parse("quay.io/podman/hello:latest").unwrap(); + fn registry() -> Result<(), InvalidImageError> { + let mut image = Image::parse("quay.io/podman/hello:latest")?; assert_parts_eq( &image, Some("quay.io"), @@ -742,7 +741,7 @@ mod tests { ); // Replace registry - image.set_registry(Some(Name::new("docker.io").unwrap())); + image.set_registry(Some(Name::new("docker.io")?)); assert_parts_eq( &image, Some("docker.io"), @@ -755,18 +754,38 @@ mod tests { assert_parts_eq(&image, None, "podman/hello", Some("latest")); // Add registry - image.set_registry(Some(Name::new("quay.io").unwrap())); + image.set_registry(Some(Name::new("quay.io")?)); assert_parts_eq( &image, Some("quay.io"), "quay.io/podman/hello", Some("latest"), ); + + // Registry with port + let image = Image::parse("quay.io:443/podman/hello")?; + assert_parts_eq( + &image, + Some("quay.io:443"), + "quay.io:443/podman/hello", + None, + ); + + // Registry with port and tag + let image = Image::parse("quay.io:443/podman/hello:latest")?; + assert_parts_eq( + &image, + Some("quay.io:443"), + "quay.io:443/podman/hello", + Some("latest"), + ); + + Ok(()) } #[test] - fn name() { - let mut image = Image::parse("quay.io/podman/hello:latest").unwrap(); + fn name() -> Result<(), InvalidImageError> { + let mut image = Image::parse("quay.io/podman/hello:latest")?; assert_parts_eq( &image, Some("quay.io"), @@ -775,7 +794,7 @@ mod tests { ); assert_eq!(image.as_name(), "quay.io/podman/hello"); - image.set_name(Name::new("docker.io/library/busybox").unwrap()); + image.set_name(Name::new("docker.io/library/busybox")?); assert_parts_eq( &image, Some("docker.io"), @@ -783,21 +802,23 @@ mod tests { Some("latest"), ); assert_eq!(image.as_name(), "docker.io/library/busybox"); + + Ok(()) } #[test] - fn tag_and_digest() { - let mut image = Image::parse("quay.io/podman/hello:latest").unwrap(); + fn tag_and_digest() -> Result<(), InvalidImageError> { + let mut image = Image::parse("quay.io/podman/hello:latest")?; assert_parts_eq( &image, Some("quay.io"), "quay.io/podman/hello", Some("latest"), ); - assert_eq!(image.as_tag().unwrap(), "latest"); + assert_eq!(image.as_tag().map(Tag::into_inner), Some("latest")); // Replace tag - image.set_tag(Some(Tag::new("test").unwrap())); + image.set_tag(Some(Tag::new("test")?)); assert_parts_eq( &image, Some("quay.io"), @@ -807,17 +828,18 @@ mod tests { // Replace tag with digest let digest = "sha256:075975296016084fc66b59c35c9d4504765d95aadcd5469f28d2b75750348fc5"; - image.set_digest(Some(Digest::new(digest).unwrap())); + image.set_digest(Some(Digest::new(digest)?)); assert_parts_eq( &image, Some("quay.io"), "quay.io/podman/hello", Some(digest), ); - assert_eq!(image.as_digest().unwrap(), digest); + assert_eq!(image.as_digest().map(Digest::into_inner), Some(digest)); + assert_eq!(image, format!("quay.io/podman/hello@{digest}").as_str()); // Replace digest - image.set_digest(Some(Digest::new("algo:data").unwrap())); + image.set_digest(Some(Digest::new("algo:data")?)); assert_parts_eq( &image, Some("quay.io"), @@ -830,12 +852,14 @@ mod tests { assert_parts_eq(&image, Some("quay.io"), "quay.io/podman/hello", None); // Add tag back - image.set_tag(Some(Tag::new("latest").unwrap())); + image.set_tag(Some(Tag::new("latest")?)); assert_parts_eq( &image, Some("quay.io"), "quay.io/podman/hello", Some("latest"), ); + + Ok(()) } } diff --git a/src/service/image/name.rs b/src/service/image/name.rs index 554bb1f..d8ee86c 100644 --- a/src/service/image/name.rs +++ b/src/service/image/name.rs @@ -5,6 +5,7 @@ use std::{ cmp::Ordering, fmt::{self, Display, Formatter}, hash::{Hash, Hasher}, + num::ParseIntError, }; use thiserror::Error; @@ -14,12 +15,11 @@ use super::char_is_alnum; /// Validated name for a container [`Image`](super::Image). /// /// A name may or may not contain a registry. Some container engines, like -/// docker, use a default registry (e.g. "docker.io") or can be configured with one. It is often +/// Docker, use a default registry (e.g. "docker.io") or can be configured with one. It is often /// recommended to use a full name with a registry for both performance reasons and clarity. /// /// When validating a name, it is split into parts by splitting on slash (/) characters, then each -/// part is validated. If the name contains more than one part, and first part contains a dot (.) -/// character, it is treated as the registry. +/// part is validated. If the first part contains a dot (.) character, it is treated as the registry. /// /// Image name parts must: /// @@ -28,6 +28,8 @@ use super::char_is_alnum; /// or underscores (_). /// - Not be empty. /// - Start and end with a lowercase ASCII letter (a-z) or digit (0-9). +/// +/// Additionally, registries may contain a colon (:) that denote a port number. #[derive(Debug, Clone, Copy, Eq)] pub struct Name<'a> { /// Inner string slice. @@ -41,14 +43,15 @@ impl<'a> Name<'a> { /// Validate a [`Name`]. /// /// The name is split into parts by splitting on slash (/) characters, then each part is - /// validated. If the name contains more than one part, and first part contains a dot (.) - /// character, it is treated as the registry. + /// validated. If the first part contains a dot (.) character, it is treated as the registry. /// /// # Errors /// /// Returns an error if: /// /// - The part has more than one separator (., _, __, any number of -) in a row. + /// - The part is not the registry and contains a colon (:). + /// - The registry's port is not a valid port number. /// - The part contains a character other than a lowercase ASCII letter (a-z), digit (0-9), /// dash (-), dot (.), or underscore (_). /// - The part is empty. @@ -67,6 +70,10 @@ impl<'a> Name<'a> { /// let name = Name::new("quay.io/podman/hello").unwrap(); /// assert_eq!(name.registry().unwrap(), "quay.io"); /// + /// // Registry with a port. + /// let name = Name::new("quay.io:443/podman/hello").unwrap(); + /// assert_eq!(name.registry().unwrap(), "quay.io:443"); + /// /// // Non-ASCII characters are not allowed in image names. /// assert!(Name::new("cliché").is_err()); /// ``` @@ -74,14 +81,21 @@ impl<'a> Name<'a> { let mut split = name.split('/'); let mut registry_end = None; - if let Some(first) = split.next() { - validate_part(first)?; - if let Some(second) = split.next() { - validate_part(second)?; - if first.contains('.') { - registry_end = Some(first.len()); + if let Some(mut first) = split.next() { + if first.contains('.') { + // First part is a registry, check port. + registry_end = Some(first.len()); + if let Some((host, port)) = first.split_once(':') { + port.parse::() + .map_err(|source| InvalidNamePartError::RegistryPort { + source, + port: port.to_owned(), + })?; + first = host; } } + + validate_part(first)?; } for part in split { @@ -235,6 +249,15 @@ pub enum InvalidNamePartError { /// Name parts must end with a lowercase ASCII letter (a-z) or a digit (0-9). #[error("image name parts must end with a lowercase ASCII letter (a-z) or a digit (0-9)")] End, + + /// Registry ports must be a [`u16`]. + #[error("image registry port `{port}` is not a valid port number")] + RegistryPort { + /// Source of the error. + source: ParseIntError, + /// The string that was attempted to parse as a port. + port: String, + }, } impl<'a> AsRef for Name<'a> { @@ -301,6 +324,8 @@ impl<'a> Display for Name<'a> { #[cfg(test)] mod tests { + use std::fmt::Write; + use pomsky_macro::pomsky; use proptest::{prop_assert_eq, proptest}; @@ -341,7 +366,10 @@ mod tests { /// Test `registry_end` is accurately parsed. #[test] #[ignore] - fn registry(registry in REGISTRY, rest in NAME) { + fn registry(mut registry in REGISTRY, port: Option, rest in NAME) { + if let Some(port) = port { + write!(registry, ":{port}")?; + } let name = format!("{registry}/{rest}"); let name = Name::new(&name)?; prop_assert_eq!(name.registry(), Some(registry.as_str()));