Skip to content

Commit 28643e4

Browse files
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
1 parent 63423e7 commit 28643e4

File tree

9 files changed

+367
-57
lines changed

9 files changed

+367
-57
lines changed

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageName.java

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
* A Docker image name of the form {@literal "docker.io/library/ubuntu"}.
2323
*
2424
* @author Phillip Webb
25+
* @author Scott Frederick
2526
* @since 2.3.0
2627
* @see ImageReference
28+
* @see ImageReferenceParser
2729
* @see #of(String)
2830
*/
2931
public class ImageName {
@@ -41,11 +43,10 @@ public class ImageName {
4143
private final String string;
4244

4345
ImageName(String domain, String name) {
44-
Assert.hasText(domain, "Domain must not be empty");
4546
Assert.hasText(name, "Name must not be empty");
46-
this.domain = domain;
47-
this.name = name;
48-
this.string = domain + "/" + name;
47+
this.domain = getDomainOrDefault(domain);
48+
this.name = getNameWithDefaultPath(this.domain, name);
49+
this.string = this.domain + "/" + this.name;
4950
}
5051

5152
/**
@@ -100,6 +101,20 @@ public String toLegacyString() {
100101
return this.string;
101102
}
102103

104+
private String getDomainOrDefault(String domain) {
105+
if (domain == null || LEGACY_DOMAIN.equals(domain)) {
106+
return DEFAULT_DOMAIN;
107+
}
108+
return domain;
109+
}
110+
111+
private String getNameWithDefaultPath(String domain, String name) {
112+
if (DEFAULT_DOMAIN.equals(domain) && !name.contains("/")) {
113+
return OFFICIAL_REPOSITORY_NAME + "/" + name;
114+
}
115+
return name;
116+
}
117+
103118
/**
104119
* Create a new {@link ImageName} from the given value. The following value forms can
105120
* be used:
@@ -112,26 +127,9 @@ public String toLegacyString() {
112127
* @return an {@link ImageName} instance
113128
*/
114129
public static ImageName of(String value) {
115-
String[] split = split(value);
116-
return new ImageName(split[0], split[1]);
117-
}
118-
119-
static String[] split(String value) {
120130
Assert.hasText(value, "Value must not be empty");
121-
String domain = DEFAULT_DOMAIN;
122-
int firstSlash = value.indexOf('/');
123-
if (firstSlash != -1) {
124-
String firstSegment = value.substring(0, firstSlash);
125-
if (firstSegment.contains(".") || firstSegment.contains(":") || "localhost".equals(firstSegment)) {
126-
domain = LEGACY_DOMAIN.equals(firstSegment) ? DEFAULT_DOMAIN : firstSegment;
127-
value = value.substring(firstSlash + 1);
128-
}
129-
}
130-
if (DEFAULT_DOMAIN.equals(domain) && !value.contains("/")) {
131-
value = OFFICIAL_REPOSITORY_NAME + "/" + value;
132-
}
133-
return new String[] { domain, value };
134-
131+
ImageReferenceParser parser = ImageReferenceParser.of(value);
132+
return new ImageName(parser.getDomain(), parser.getName());
135133
}
136134

137135
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/type/ImageReference.java

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@
3030
* @author Scott Frederick
3131
* @since 2.3.0
3232
* @see ImageName
33-
* @see <a href=
34-
* "https://stackoverflow.com/questions/37861791/how-are-docker-image-names-parsed">How
35-
* are Docker image names parsed?</a>
33+
* @see ImageReferenceParser
3634
*/
3735
public final class ImageReference {
3836

@@ -180,7 +178,7 @@ public static ImageReference forJarFile(File jarFile) {
180178
filename = filename.substring(0, filename.length() - 4);
181179
int firstDot = filename.indexOf('.');
182180
if (firstDot == -1) {
183-
return ImageReference.of(filename);
181+
return of(filename);
184182
}
185183
String name = filename.substring(0, firstDot);
186184
String version = filename.substring(firstDot + 1);
@@ -226,8 +224,9 @@ public static ImageReference random(String prefix, int randomLength) {
226224
*/
227225
public static ImageReference of(String value) {
228226
Assert.hasText(value, "Value must not be null");
229-
String[] domainAndValue = ImageName.split(value);
230-
return of(domainAndValue[0], domainAndValue[1]);
227+
ImageReferenceParser parser = ImageReferenceParser.of(value);
228+
ImageName name = new ImageName(parser.getDomain(), parser.getName());
229+
return new ImageReference(name, parser.getTag(), parser.getDigest());
231230
}
232231

233232
/**
@@ -261,21 +260,4 @@ public static ImageReference of(ImageName name, String tag, String digest) {
261260
return new ImageReference(name, tag, digest);
262261
}
263262

264-
private static ImageReference of(String domain, String value) {
265-
String digest = null;
266-
int lastAt = value.indexOf('@');
267-
if (lastAt != -1) {
268-
digest = value.substring(lastAt + 1);
269-
value = value.substring(0, lastAt);
270-
}
271-
String tag = null;
272-
int firstColon = value.indexOf(':');
273-
if (firstColon != -1) {
274-
tag = value.substring(firstColon + 1);
275-
value = value.substring(0, firstColon);
276-
}
277-
ImageName name = new ImageName(domain, value);
278-
return new ImageReference(name, tag, digest);
279-
}
280-
281263
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright 2012-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.buildpack.platform.docker.type;
18+
19+
import java.util.regex.Matcher;
20+
import java.util.regex.Pattern;
21+
22+
/**
23+
* A parser for Docker image references in the form
24+
* {@code [domainHost:port/][path/]name[:tag][@digest]}.
25+
*
26+
* @author Scott Frederick
27+
* @see <a href=
28+
* "https://github.com/docker/distribution/blob/master/reference/reference.go">Docker
29+
* grammar reference</a>
30+
* @see <a href=
31+
* "https://github.com/docker/distribution/blob/master/reference/regexp.go">Docker grammar
32+
* implementation</a>
33+
* @see <a href=
34+
* "https://stackoverflow.com/questions/37861791/how-are-docker-image-names-parsed">How
35+
* are Docker image names parsed?</a>
36+
*/
37+
final class ImageReferenceParser {
38+
39+
private static final String DOMAIN_SEGMENT_REGEX = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
40+
41+
private static final String DOMAIN_PORT_REGEX = "[0-9]+";
42+
43+
private static final String DOMAIN_REGEX = oneOf(
44+
groupOf(DOMAIN_SEGMENT_REGEX, repeating("[.]", DOMAIN_SEGMENT_REGEX)),
45+
groupOf(DOMAIN_SEGMENT_REGEX, "[:]", DOMAIN_PORT_REGEX),
46+
groupOf(DOMAIN_SEGMENT_REGEX, repeating("[.]", DOMAIN_SEGMENT_REGEX), "[:]", DOMAIN_PORT_REGEX),
47+
"localhost");
48+
49+
private static final String NAME_CHARS_REGEX = "[a-z0-9]+";
50+
51+
private static final String NAME_SEPARATOR_REGEX = "(?:[._]|__|[-]*)";
52+
53+
private static final String NAME_SEGMENT_REGEX = groupOf(NAME_CHARS_REGEX,
54+
optional(repeating(NAME_SEPARATOR_REGEX, NAME_CHARS_REGEX)));
55+
56+
private static final String NAME_PATH_REGEX = groupOf(NAME_SEGMENT_REGEX,
57+
optional(repeating("[/]", NAME_SEGMENT_REGEX)));
58+
59+
private static final String DIGEST_ALGORITHM_SEGMENT_REGEX = "[A-Za-z][A-Za-z0-9]*";
60+
61+
private static final String DIGEST_ALGORITHM_SEPARATOR_REGEX = "[-_+.]";
62+
63+
private static final String DIGEST_ALGORITHM_REGEX = groupOf(DIGEST_ALGORITHM_SEGMENT_REGEX,
64+
optional(repeating(DIGEST_ALGORITHM_SEPARATOR_REGEX, DIGEST_ALGORITHM_SEGMENT_REGEX)));
65+
66+
private static final String DIGEST_VALUE_REGEX = "[0-9A-Fa-f]{32,}";
67+
68+
private static final String DIGEST_REGEX = groupOf(DIGEST_ALGORITHM_REGEX, "[:]", DIGEST_VALUE_REGEX);
69+
70+
private static final String TAG_REGEX = "[\\w][\\w.-]{0,127}";
71+
72+
private static final String DOMAIN_CAPTURE_GROUP = "domain";
73+
74+
private static final String NAME_CAPTURE_GROUP = "name";
75+
76+
private static final String TAG_CAPTURE_GROUP = "tag";
77+
78+
private static final String DIGEST_CAPTURE_GROUP = "digest";
79+
80+
private static final Pattern REFERENCE_REGEX_PATTERN = patternOf(anchored(
81+
optional(captureOf(DOMAIN_CAPTURE_GROUP, DOMAIN_REGEX), "[/]"),
82+
captureOf(NAME_CAPTURE_GROUP, NAME_PATH_REGEX), optional("[:]", captureOf(TAG_CAPTURE_GROUP, TAG_REGEX)),
83+
optional("[@]", captureOf(DIGEST_CAPTURE_GROUP, DIGEST_REGEX))));
84+
85+
private final String domain;
86+
87+
private final String name;
88+
89+
private final String tag;
90+
91+
private final String digest;
92+
93+
private ImageReferenceParser(String domain, String name, String tag, String digest) {
94+
this.domain = domain;
95+
this.name = name;
96+
this.tag = tag;
97+
this.digest = digest;
98+
}
99+
100+
String getDomain() {
101+
return this.domain;
102+
}
103+
104+
String getName() {
105+
return this.name;
106+
}
107+
108+
String getTag() {
109+
return this.tag;
110+
}
111+
112+
String getDigest() {
113+
return this.digest;
114+
}
115+
116+
static ImageReferenceParser of(String reference) {
117+
Matcher matcher = REFERENCE_REGEX_PATTERN.matcher(reference);
118+
if (!matcher.matches()) {
119+
throw new IllegalArgumentException("Unable to parse image reference \"" + reference + "\". "
120+
+ "Image reference must be in the form \"[domainHost:port/][path/]name[:tag][@digest]\", "
121+
+ "with \"path\" and \"name\" containing only [a-z0-9][.][_][-]");
122+
}
123+
return new ImageReferenceParser(matcher.group(DOMAIN_CAPTURE_GROUP), matcher.group(NAME_CAPTURE_GROUP),
124+
matcher.group(TAG_CAPTURE_GROUP), matcher.group(DIGEST_CAPTURE_GROUP));
125+
}
126+
127+
private static Pattern patternOf(String... expressions) {
128+
return Pattern.compile(join(expressions));
129+
}
130+
131+
private static String groupOf(String... expressions) {
132+
return "(?:" + join(expressions) + ')';
133+
}
134+
135+
private static String captureOf(String groupName, String... expressions) {
136+
return "(?<" + groupName + ">" + join(expressions) + ')';
137+
}
138+
139+
private static String oneOf(String... expressions) {
140+
return groupOf(String.join("|", expressions));
141+
}
142+
143+
private static String optional(String... expressions) {
144+
return groupOf(join(expressions)) + '?';
145+
}
146+
147+
private static String repeating(String... expressions) {
148+
return groupOf(join(expressions)) + '+';
149+
}
150+
151+
private static String anchored(String... expressions) {
152+
return '^' + join(expressions) + '$';
153+
}
154+
155+
private static String join(String... expressions) {
156+
return String.join("", expressions);
157+
}
158+
159+
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuildRequestTests.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ void withBuilderUpdatesBuilder() throws IOException {
100100
@Test
101101
void withBuilderWhenHasDigestUpdatesBuilder() throws IOException {
102102
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")).withBuilder(ImageReference
103-
.of("spring/builder:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"));
103+
.of("spring/builder@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"));
104104
assertThat(request.getBuilder().toString()).isEqualTo(
105-
"docker.io/spring/builder:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
105+
"docker.io/spring/builder@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
106106
}
107107

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

123123
@Test

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/BuilderTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ void buildInvokesBuilderWithRunImageInDigestForm() throws Exception {
115115
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
116116
.willAnswer(withPulledImage(builderImage));
117117
given(docker.image().pull(eq(ImageReference.of(
118-
"docker.io/cloudfoundry/run:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
118+
"docker.io/cloudfoundry/run@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
119119
any())).willAnswer(withPulledImage(runImage));
120120
Builder builder = new Builder(BuildLog.to(out), docker);
121121
BuildRequest request = getTestRequest();

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/type/ImageNameTests.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
* Tests for {@link ImageName}.
2626
*
2727
* @author Phillip Webb
28+
* @author Scott Frederick
2829
*/
2930
class ImageNameTests {
3031

@@ -99,11 +100,13 @@ void ofWhenNameIsEmptyThrowsException() {
99100
void hashCodeAndEquals() {
100101
ImageName n1 = ImageName.of("ubuntu");
101102
ImageName n2 = ImageName.of("library/ubuntu");
102-
ImageName n3 = ImageName.of("docker.io/library/ubuntu");
103-
ImageName n4 = ImageName.of("index.docker.io/library/ubuntu");
104-
ImageName n5 = ImageName.of("alpine");
105-
assertThat(n1.hashCode()).isEqualTo(n2.hashCode()).isEqualTo(n3.hashCode()).isEqualTo(n4.hashCode());
106-
assertThat(n1).isEqualTo(n1).isEqualTo(n2).isEqualTo(n3).isEqualTo(n4).isNotEqualTo(n5);
103+
ImageName n3 = ImageName.of("docker.io/ubuntu");
104+
ImageName n4 = ImageName.of("docker.io/library/ubuntu");
105+
ImageName n5 = ImageName.of("index.docker.io/library/ubuntu");
106+
ImageName n6 = ImageName.of("alpine");
107+
assertThat(n1.hashCode()).isEqualTo(n2.hashCode()).isEqualTo(n3.hashCode()).isEqualTo(n4.hashCode())
108+
.isEqualTo(n5.hashCode());
109+
assertThat(n1).isEqualTo(n1).isEqualTo(n2).isEqualTo(n3).isEqualTo(n4).isNotEqualTo(n6);
107110
}
108111

109112
}

0 commit comments

Comments
 (0)