Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(service): image registry with port is valid #26

Merged
merged 1 commit into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 53 additions & 29 deletions src/service/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,19 @@ impl Image {
fn parse_impl(
image: &str,
) -> Result<(Option<usize>, Option<TagOrDigestStart>), 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,
Expand Down Expand Up @@ -713,7 +713,6 @@ const fn char_is_alnum(char: char) -> bool {
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;

Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -775,29 +794,31 @@ 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"),
"docker.io/library/busybox",
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"),
Expand All @@ -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"),
Expand All @@ -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(())
}
}
52 changes: 40 additions & 12 deletions src/service/image/name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::{
cmp::Ordering,
fmt::{self, Display, Formatter},
hash::{Hash, Hasher},
num::ParseIntError,
};

use thiserror::Error;
Expand All @@ -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:
///
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -67,21 +70,32 @@ 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());
/// ```
pub fn new(name: &'a str) -> Result<Self, InvalidNamePartError> {
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::<u16>()
.map_err(|source| InvalidNamePartError::RegistryPort {
source,
port: port.to_owned(),
})?;
first = host;
}
}

validate_part(first)?;
}

for part in split {
Expand Down Expand Up @@ -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<str> for Name<'a> {
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -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<u16>, 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()));
Expand Down