Skip to content

Commit

Permalink
Validate image references before passing to CNB builder
Browse files Browse the repository at this point in the history
Prior to this commit, an image name or run image name derived from
the project name or provided by the user would be passed to the CNB
builder without validation by the Maven plugin build-image goal or
Gradle plugin bootBuildImage task. This could lead to error messages
from the plugins that are difficult to understand and diagnose.

This commit makes parsing of the image names more strict, based on
the grammar implemented by the Docker go library. This provides
validation of the image names before passing them to the builder,
with a more descriptive error message when parsing and validation
fails.

Fixes gh-21495
  • Loading branch information
scottfrederick committed Jun 18, 2020
1 parent 63423e7 commit 28643e4
Show file tree
Hide file tree
Showing 9 changed files with 367 additions and 57 deletions.
Expand Up @@ -22,8 +22,10 @@
* A Docker image name of the form {@literal "docker.io/library/ubuntu"}.
*
* @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0
* @see ImageReference
* @see ImageReferenceParser
* @see #of(String)
*/
public class ImageName {
Expand All @@ -41,11 +43,10 @@ public class ImageName {
private final String string;

ImageName(String domain, String name) {
Assert.hasText(domain, "Domain must not be empty");
Assert.hasText(name, "Name must not be empty");
this.domain = domain;
this.name = name;
this.string = domain + "/" + name;
this.domain = getDomainOrDefault(domain);
this.name = getNameWithDefaultPath(this.domain, name);
this.string = this.domain + "/" + this.name;
}

/**
Expand Down Expand Up @@ -100,6 +101,20 @@ public String toLegacyString() {
return this.string;
}

private String getDomainOrDefault(String domain) {
if (domain == null || LEGACY_DOMAIN.equals(domain)) {
return DEFAULT_DOMAIN;
}
return domain;
}

private String getNameWithDefaultPath(String domain, String name) {
if (DEFAULT_DOMAIN.equals(domain) && !name.contains("/")) {
return OFFICIAL_REPOSITORY_NAME + "/" + name;
}
return name;
}

/**
* Create a new {@link ImageName} from the given value. The following value forms can
* be used:
Expand All @@ -112,26 +127,9 @@ public String toLegacyString() {
* @return an {@link ImageName} instance
*/
public static ImageName of(String value) {
String[] split = split(value);
return new ImageName(split[0], split[1]);
}

static String[] split(String value) {
Assert.hasText(value, "Value must not be empty");
String domain = DEFAULT_DOMAIN;
int firstSlash = value.indexOf('/');
if (firstSlash != -1) {
String firstSegment = value.substring(0, firstSlash);
if (firstSegment.contains(".") || firstSegment.contains(":") || "localhost".equals(firstSegment)) {
domain = LEGACY_DOMAIN.equals(firstSegment) ? DEFAULT_DOMAIN : firstSegment;
value = value.substring(firstSlash + 1);
}
}
if (DEFAULT_DOMAIN.equals(domain) && !value.contains("/")) {
value = OFFICIAL_REPOSITORY_NAME + "/" + value;
}
return new String[] { domain, value };

ImageReferenceParser parser = ImageReferenceParser.of(value);
return new ImageName(parser.getDomain(), parser.getName());
}

}
Expand Up @@ -30,9 +30,7 @@
* @author Scott Frederick
* @since 2.3.0
* @see ImageName
* @see <a href=
* "https://stackoverflow.com/questions/37861791/how-are-docker-image-names-parsed">How
* are Docker image names parsed?</a>
* @see ImageReferenceParser
*/
public final class ImageReference {

Expand Down Expand Up @@ -180,7 +178,7 @@ public static ImageReference forJarFile(File jarFile) {
filename = filename.substring(0, filename.length() - 4);
int firstDot = filename.indexOf('.');
if (firstDot == -1) {
return ImageReference.of(filename);
return of(filename);
}
String name = filename.substring(0, firstDot);
String version = filename.substring(firstDot + 1);
Expand Down Expand Up @@ -226,8 +224,9 @@ public static ImageReference random(String prefix, int randomLength) {
*/
public static ImageReference of(String value) {
Assert.hasText(value, "Value must not be null");
String[] domainAndValue = ImageName.split(value);
return of(domainAndValue[0], domainAndValue[1]);
ImageReferenceParser parser = ImageReferenceParser.of(value);
ImageName name = new ImageName(parser.getDomain(), parser.getName());
return new ImageReference(name, parser.getTag(), parser.getDigest());
}

/**
Expand Down Expand Up @@ -261,21 +260,4 @@ public static ImageReference of(ImageName name, String tag, String digest) {
return new ImageReference(name, tag, digest);
}

private static ImageReference of(String domain, String value) {
String digest = null;
int lastAt = value.indexOf('@');
if (lastAt != -1) {
digest = value.substring(lastAt + 1);
value = value.substring(0, lastAt);
}
String tag = null;
int firstColon = value.indexOf(':');
if (firstColon != -1) {
tag = value.substring(firstColon + 1);
value = value.substring(0, firstColon);
}
ImageName name = new ImageName(domain, value);
return new ImageReference(name, tag, digest);
}

}
@@ -0,0 +1,159 @@
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.buildpack.platform.docker.type;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* A parser for Docker image references in the form
* {@code [domainHost:port/][path/]name[:tag][@digest]}.
*
* @author Scott Frederick
* @see <a href=
* "https://github.com/docker/distribution/blob/master/reference/reference.go">Docker
* grammar reference</a>
* @see <a href=
* "https://github.com/docker/distribution/blob/master/reference/regexp.go">Docker grammar
* implementation</a>
* @see <a href=
* "https://stackoverflow.com/questions/37861791/how-are-docker-image-names-parsed">How
* are Docker image names parsed?</a>
*/
final class ImageReferenceParser {

private static final String DOMAIN_SEGMENT_REGEX = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";

private static final String DOMAIN_PORT_REGEX = "[0-9]+";

private static final String DOMAIN_REGEX = oneOf(
groupOf(DOMAIN_SEGMENT_REGEX, repeating("[.]", DOMAIN_SEGMENT_REGEX)),
groupOf(DOMAIN_SEGMENT_REGEX, "[:]", DOMAIN_PORT_REGEX),
groupOf(DOMAIN_SEGMENT_REGEX, repeating("[.]", DOMAIN_SEGMENT_REGEX), "[:]", DOMAIN_PORT_REGEX),
"localhost");

private static final String NAME_CHARS_REGEX = "[a-z0-9]+";

private static final String NAME_SEPARATOR_REGEX = "(?:[._]|__|[-]*)";

private static final String NAME_SEGMENT_REGEX = groupOf(NAME_CHARS_REGEX,
optional(repeating(NAME_SEPARATOR_REGEX, NAME_CHARS_REGEX)));

private static final String NAME_PATH_REGEX = groupOf(NAME_SEGMENT_REGEX,
optional(repeating("[/]", NAME_SEGMENT_REGEX)));

private static final String DIGEST_ALGORITHM_SEGMENT_REGEX = "[A-Za-z][A-Za-z0-9]*";

private static final String DIGEST_ALGORITHM_SEPARATOR_REGEX = "[-_+.]";

private static final String DIGEST_ALGORITHM_REGEX = groupOf(DIGEST_ALGORITHM_SEGMENT_REGEX,
optional(repeating(DIGEST_ALGORITHM_SEPARATOR_REGEX, DIGEST_ALGORITHM_SEGMENT_REGEX)));

private static final String DIGEST_VALUE_REGEX = "[0-9A-Fa-f]{32,}";

private static final String DIGEST_REGEX = groupOf(DIGEST_ALGORITHM_REGEX, "[:]", DIGEST_VALUE_REGEX);

private static final String TAG_REGEX = "[\\w][\\w.-]{0,127}";

private static final String DOMAIN_CAPTURE_GROUP = "domain";

private static final String NAME_CAPTURE_GROUP = "name";

private static final String TAG_CAPTURE_GROUP = "tag";

private static final String DIGEST_CAPTURE_GROUP = "digest";

private static final Pattern REFERENCE_REGEX_PATTERN = patternOf(anchored(
optional(captureOf(DOMAIN_CAPTURE_GROUP, DOMAIN_REGEX), "[/]"),
captureOf(NAME_CAPTURE_GROUP, NAME_PATH_REGEX), optional("[:]", captureOf(TAG_CAPTURE_GROUP, TAG_REGEX)),
optional("[@]", captureOf(DIGEST_CAPTURE_GROUP, DIGEST_REGEX))));

private final String domain;

private final String name;

private final String tag;

private final String digest;

private ImageReferenceParser(String domain, String name, String tag, String digest) {
this.domain = domain;
this.name = name;
this.tag = tag;
this.digest = digest;
}

String getDomain() {
return this.domain;
}

String getName() {
return this.name;
}

String getTag() {
return this.tag;
}

String getDigest() {
return this.digest;
}

static ImageReferenceParser of(String reference) {
Matcher matcher = REFERENCE_REGEX_PATTERN.matcher(reference);
if (!matcher.matches()) {
throw new IllegalArgumentException("Unable to parse image reference \"" + reference + "\". "
+ "Image reference must be in the form \"[domainHost:port/][path/]name[:tag][@digest]\", "
+ "with \"path\" and \"name\" containing only [a-z0-9][.][_][-]");
}
return new ImageReferenceParser(matcher.group(DOMAIN_CAPTURE_GROUP), matcher.group(NAME_CAPTURE_GROUP),
matcher.group(TAG_CAPTURE_GROUP), matcher.group(DIGEST_CAPTURE_GROUP));
}

private static Pattern patternOf(String... expressions) {
return Pattern.compile(join(expressions));
}

private static String groupOf(String... expressions) {
return "(?:" + join(expressions) + ')';
}

private static String captureOf(String groupName, String... expressions) {
return "(?<" + groupName + ">" + join(expressions) + ')';
}

private static String oneOf(String... expressions) {
return groupOf(String.join("|", expressions));
}

private static String optional(String... expressions) {
return groupOf(join(expressions)) + '?';
}

private static String repeating(String... expressions) {
return groupOf(join(expressions)) + '+';
}

private static String anchored(String... expressions) {
return '^' + join(expressions) + '$';
}

private static String join(String... expressions) {
return String.join("", expressions);
}

}
Expand Up @@ -100,9 +100,9 @@ void withBuilderUpdatesBuilder() throws IOException {
@Test
void withBuilderWhenHasDigestUpdatesBuilder() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")).withBuilder(ImageReference
.of("spring/builder:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"));
.of("spring/builder@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"));
assertThat(request.getBuilder().toString()).isEqualTo(
"docker.io/spring/builder:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
"docker.io/spring/builder@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
}

@Test
Expand All @@ -115,9 +115,9 @@ void withRunImageUpdatesRunImage() throws IOException {
@Test
void withRunImageWhenHasDigestUpdatesRunImage() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")).withRunImage(ImageReference
.of("example.com/custom/run-image:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"));
.of("example.com/custom/run-image@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"));
assertThat(request.getRunImage().toString()).isEqualTo(
"example.com/custom/run-image:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
"example.com/custom/run-image@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
}

@Test
Expand Down
Expand Up @@ -115,7 +115,7 @@ void buildInvokesBuilderWithRunImageInDigestForm() throws Exception {
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
.willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of(
"docker.io/cloudfoundry/run:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
"docker.io/cloudfoundry/run@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
any())).willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker);
BuildRequest request = getTestRequest();
Expand Down
Expand Up @@ -25,6 +25,7 @@
* Tests for {@link ImageName}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class ImageNameTests {

Expand Down Expand Up @@ -99,11 +100,13 @@ void ofWhenNameIsEmptyThrowsException() {
void hashCodeAndEquals() {
ImageName n1 = ImageName.of("ubuntu");
ImageName n2 = ImageName.of("library/ubuntu");
ImageName n3 = ImageName.of("docker.io/library/ubuntu");
ImageName n4 = ImageName.of("index.docker.io/library/ubuntu");
ImageName n5 = ImageName.of("alpine");
assertThat(n1.hashCode()).isEqualTo(n2.hashCode()).isEqualTo(n3.hashCode()).isEqualTo(n4.hashCode());
assertThat(n1).isEqualTo(n1).isEqualTo(n2).isEqualTo(n3).isEqualTo(n4).isNotEqualTo(n5);
ImageName n3 = ImageName.of("docker.io/ubuntu");
ImageName n4 = ImageName.of("docker.io/library/ubuntu");
ImageName n5 = ImageName.of("index.docker.io/library/ubuntu");
ImageName n6 = ImageName.of("alpine");
assertThat(n1.hashCode()).isEqualTo(n2.hashCode()).isEqualTo(n3.hashCode()).isEqualTo(n4.hashCode())
.isEqualTo(n5.hashCode());
assertThat(n1).isEqualTo(n1).isEqualTo(n2).isEqualTo(n3).isEqualTo(n4).isNotEqualTo(n6);
}

}

0 comments on commit 28643e4

Please sign in to comment.