Validate image references before passing to CNB builder

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
This commit is contained in:
Scott Frederick 2020-06-17 12:21:54 -05:00
parent 63423e7d71
commit 28643e4d2d
9 changed files with 367 additions and 57 deletions

View File

@ -22,8 +22,10 @@ import org.springframework.util.Assert;
* 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 {
@ -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;
}
/**
@ -100,6 +101,20 @@ public class ImageName {
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:
@ -112,26 +127,9 @@ public class ImageName {
* @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());
}
}

View File

@ -30,9 +30,7 @@ import org.springframework.util.ObjectUtils;
* @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 {
@ -180,7 +178,7 @@ public final class ImageReference {
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);
@ -226,8 +224,9 @@ public final class ImageReference {
*/
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());
}
/**
@ -261,21 +260,4 @@ public final class ImageReference {
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);
}
}

View File

@ -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);
}
}

View File

@ -100,9 +100,9 @@ public class BuildRequestTests {
@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
@ -115,9 +115,9 @@ public class BuildRequestTests {
@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

View File

@ -115,7 +115,7 @@ class BuilderTests {
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();

View File

@ -25,6 +25,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link ImageName}.
*
* @author Phillip Webb
* @author Scott Frederick
*/
class ImageNameTests {
@ -99,11 +100,13 @@ class ImageNameTests {
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);
}
}

View File

@ -0,0 +1,158 @@
/*
* 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 org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link ImageReferenceParser}.
*
* @author Scott Frederick
*/
class ImageReferenceParserTests {
@Test
void unableToParseWithUppercaseInName() {
assertThatIllegalArgumentException().isThrownBy(() -> ImageReferenceParser.of("Test"))
.withMessageContaining("Test");
}
@Test
void parsesName() {
ImageReferenceParser parser = ImageReferenceParser.of("ubuntu");
assertThat(parser.getDomain()).isNull();
assertThat(parser.getName()).isEqualTo("ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesNameWithPath() {
ImageReferenceParser parser = ImageReferenceParser.of("library/ubuntu");
assertThat(parser.getDomain()).isNull();
assertThat(parser.getName()).isEqualTo("library/ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesNameWithLongPath() {
ImageReferenceParser parser = ImageReferenceParser.of("path1/path2/path3/ubuntu");
assertThat(parser.getDomain()).isNull();
assertThat(parser.getName()).isEqualTo("path1/path2/path3/ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesDomainAndNameWithPath() {
ImageReferenceParser parser = ImageReferenceParser.of("repo.example.com/library/ubuntu");
assertThat(parser.getDomain()).isEqualTo("repo.example.com");
assertThat(parser.getName()).isEqualTo("library/ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesDomainWithPortAndNameWithPath() {
ImageReferenceParser parser = ImageReferenceParser.of("repo.example.com:8080/library/ubuntu");
assertThat(parser.getDomain()).isEqualTo("repo.example.com:8080");
assertThat(parser.getName()).isEqualTo("library/ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesSimpleDomainWithPortAndName() {
ImageReferenceParser parser = ImageReferenceParser.of("repo:8080/ubuntu");
assertThat(parser.getDomain()).isEqualTo("repo:8080");
assertThat(parser.getName()).isEqualTo("ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesSimpleDomainWithPortAndNameWithPath() {
ImageReferenceParser parser = ImageReferenceParser.of("repo:8080/library/ubuntu");
assertThat(parser.getDomain()).isEqualTo("repo:8080");
assertThat(parser.getName()).isEqualTo("library/ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesLocalhostDomainAndName() {
ImageReferenceParser parser = ImageReferenceParser.of("localhost/ubuntu");
assertThat(parser.getDomain()).isEqualTo("localhost");
assertThat(parser.getName()).isEqualTo("ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesLocalhostDomainAndNameWithPath() {
ImageReferenceParser parser = ImageReferenceParser.of("localhost/library/ubuntu");
assertThat(parser.getDomain()).isEqualTo("localhost");
assertThat(parser.getName()).isEqualTo("library/ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesNameAndTag() {
ImageReferenceParser parser = ImageReferenceParser.of("ubuntu:v1");
assertThat(parser.getDomain()).isNull();
assertThat(parser.getName()).isEqualTo("ubuntu");
assertThat(parser.getTag()).isEqualTo("v1");
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesNameAndDigest() {
ImageReferenceParser parser = ImageReferenceParser
.of("ubuntu@sha256:47bfdb88c3ae13e488167607973b7688f69d9e8c142c2045af343ec199649c09");
assertThat(parser.getDomain()).isNull();
assertThat(parser.getName()).isEqualTo("ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest())
.isEqualTo("sha256:47bfdb88c3ae13e488167607973b7688f69d9e8c142c2045af343ec199649c09");
}
@Test
void parsesReferenceWithTag() {
ImageReferenceParser parser = ImageReferenceParser.of("repo.example.com:8080/library/ubuntu:v1");
assertThat(parser.getDomain()).isEqualTo("repo.example.com:8080");
assertThat(parser.getName()).isEqualTo("library/ubuntu");
assertThat(parser.getTag()).isEqualTo("v1");
assertThat(parser.getDigest()).isNull();
}
@Test
void parsesReferenceWithDigest() {
ImageReferenceParser parser = ImageReferenceParser.of(
"repo.example.com:8080/library/ubuntu@sha256:47bfdb88c3ae13e488167607973b7688f69d9e8c142c2045af343ec199649c09");
assertThat(parser.getDomain()).isEqualTo("repo.example.com:8080");
assertThat(parser.getName()).isEqualTo("library/ubuntu");
assertThat(parser.getTag()).isNull();
assertThat(parser.getDigest())
.isEqualTo("sha256:47bfdb88c3ae13e488167607973b7688f69d9e8c142c2045af343ec199649c09");
}
}

View File

@ -135,6 +135,16 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).containsPattern("Builder lifecycle '.*' failed with status code");
}
@TestTemplate
void failsWithInvalidImageName() {
writeMainClass();
writeLongNameResource();
BuildResult result = this.gradleBuild.buildAndFail("bootBuildImage", "--imageName=example/Invalid-Image-Name");
assertThat(result.task(":bootBuildImage").getOutcome()).isEqualTo(TaskOutcome.FAILED);
assertThat(result.getOutput()).containsPattern("Unable to parse image reference")
.containsPattern("example/Invalid-Image-Name");
}
private void writeMainClass() {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs();