Commit 73b737c7 authored by Scott Frederick's avatar Scott Frederick

Merge branch '2.3.x'

Closes gh-22017
parents e8146077 28643e4d
...@@ -22,8 +22,10 @@ import org.springframework.util.Assert; ...@@ -22,8 +22,10 @@ import org.springframework.util.Assert;
* A Docker image name of the form {@literal "docker.io/library/ubuntu"}. * A Docker image name of the form {@literal "docker.io/library/ubuntu"}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
* @since 2.3.0 * @since 2.3.0
* @see ImageReference * @see ImageReference
* @see ImageReferenceParser
* @see #of(String) * @see #of(String)
*/ */
public class ImageName { public class ImageName {
...@@ -41,11 +43,10 @@ public class ImageName { ...@@ -41,11 +43,10 @@ public class ImageName {
private final String string; private final String string;
ImageName(String domain, String name) { ImageName(String domain, String name) {
Assert.hasText(domain, "Domain must not be empty");
Assert.hasText(name, "Name must not be empty"); Assert.hasText(name, "Name must not be empty");
this.domain = domain; this.domain = getDomainOrDefault(domain);
this.name = name; this.name = getNameWithDefaultPath(this.domain, name);
this.string = domain + "/" + name; this.string = this.domain + "/" + this.name;
} }
/** /**
...@@ -100,6 +101,20 @@ public class ImageName { ...@@ -100,6 +101,20 @@ public class ImageName {
return this.string; 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 * Create a new {@link ImageName} from the given value. The following value forms can
* be used: * be used:
...@@ -112,26 +127,9 @@ public class ImageName { ...@@ -112,26 +127,9 @@ public class ImageName {
* @return an {@link ImageName} instance * @return an {@link ImageName} instance
*/ */
public static ImageName of(String value) { 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"); Assert.hasText(value, "Value must not be empty");
String domain = DEFAULT_DOMAIN; ImageReferenceParser parser = ImageReferenceParser.of(value);
int firstSlash = value.indexOf('/'); return new ImageName(parser.getDomain(), parser.getName());
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 };
} }
} }
...@@ -30,9 +30,7 @@ import org.springframework.util.ObjectUtils; ...@@ -30,9 +30,7 @@ import org.springframework.util.ObjectUtils;
* @author Scott Frederick * @author Scott Frederick
* @since 2.3.0 * @since 2.3.0
* @see ImageName * @see ImageName
* @see <a href= * @see ImageReferenceParser
* "https://stackoverflow.com/questions/37861791/how-are-docker-image-names-parsed">How
* are Docker image names parsed?</a>
*/ */
public final class ImageReference { public final class ImageReference {
...@@ -180,7 +178,7 @@ public final class ImageReference { ...@@ -180,7 +178,7 @@ public final class ImageReference {
filename = filename.substring(0, filename.length() - 4); filename = filename.substring(0, filename.length() - 4);
int firstDot = filename.indexOf('.'); int firstDot = filename.indexOf('.');
if (firstDot == -1) { if (firstDot == -1) {
return ImageReference.of(filename); return of(filename);
} }
String name = filename.substring(0, firstDot); String name = filename.substring(0, firstDot);
String version = filename.substring(firstDot + 1); String version = filename.substring(firstDot + 1);
...@@ -226,8 +224,9 @@ public final class ImageReference { ...@@ -226,8 +224,9 @@ public final class ImageReference {
*/ */
public static ImageReference of(String value) { public static ImageReference of(String value) {
Assert.hasText(value, "Value must not be null"); Assert.hasText(value, "Value must not be null");
String[] domainAndValue = ImageName.split(value); ImageReferenceParser parser = ImageReferenceParser.of(value);
return of(domainAndValue[0], domainAndValue[1]); ImageName name = new ImageName(parser.getDomain(), parser.getName());
return new ImageReference(name, parser.getTag(), parser.getDigest());
} }
/** /**
...@@ -261,21 +260,4 @@ public final class ImageReference { ...@@ -261,21 +260,4 @@ public final class ImageReference {
return new ImageReference(name, tag, 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);
}
} }
/*
* 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);
}
}
...@@ -100,9 +100,9 @@ public class BuildRequestTests { ...@@ -100,9 +100,9 @@ public class BuildRequestTests {
@Test @Test
void withBuilderWhenHasDigestUpdatesBuilder() throws IOException { void withBuilderWhenHasDigestUpdatesBuilder() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")).withBuilder(ImageReference 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( assertThat(request.getBuilder().toString()).isEqualTo(
"docker.io/spring/builder:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); "docker.io/spring/builder@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
} }
@Test @Test
...@@ -115,9 +115,9 @@ public class BuildRequestTests { ...@@ -115,9 +115,9 @@ public class BuildRequestTests {
@Test @Test
void withRunImageWhenHasDigestUpdatesRunImage() throws IOException { void withRunImageWhenHasDigestUpdatesRunImage() throws IOException {
BuildRequest request = BuildRequest.forJarFile(writeTestJarFile("my-app-0.0.1.jar")).withRunImage(ImageReference 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( assertThat(request.getRunImage().toString()).isEqualTo(
"example.com/custom/run-image:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d"); "example.com/custom/run-image@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d");
} }
@Test @Test
......
...@@ -115,7 +115,7 @@ class BuilderTests { ...@@ -115,7 +115,7 @@ class BuilderTests {
given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any())) given(docker.image().pull(eq(ImageReference.of(BuildRequest.DEFAULT_BUILDER_IMAGE_NAME)), any()))
.willAnswer(withPulledImage(builderImage)); .willAnswer(withPulledImage(builderImage));
given(docker.image().pull(eq(ImageReference.of( given(docker.image().pull(eq(ImageReference.of(
"docker.io/cloudfoundry/run:@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")), "docker.io/cloudfoundry/run@sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d")),
any())).willAnswer(withPulledImage(runImage)); any())).willAnswer(withPulledImage(runImage));
Builder builder = new Builder(BuildLog.to(out), docker); Builder builder = new Builder(BuildLog.to(out), docker);
BuildRequest request = getTestRequest(); BuildRequest request = getTestRequest();
......
...@@ -25,6 +25,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException ...@@ -25,6 +25,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link ImageName}. * Tests for {@link ImageName}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Scott Frederick
*/ */
class ImageNameTests { class ImageNameTests {
...@@ -99,11 +100,13 @@ class ImageNameTests { ...@@ -99,11 +100,13 @@ class ImageNameTests {
void hashCodeAndEquals() { void hashCodeAndEquals() {
ImageName n1 = ImageName.of("ubuntu"); ImageName n1 = ImageName.of("ubuntu");
ImageName n2 = ImageName.of("library/ubuntu"); ImageName n2 = ImageName.of("library/ubuntu");
ImageName n3 = ImageName.of("docker.io/library/ubuntu"); ImageName n3 = ImageName.of("docker.io/ubuntu");
ImageName n4 = ImageName.of("index.docker.io/library/ubuntu"); ImageName n4 = ImageName.of("docker.io/library/ubuntu");
ImageName n5 = ImageName.of("alpine"); ImageName n5 = ImageName.of("index.docker.io/library/ubuntu");
assertThat(n1.hashCode()).isEqualTo(n2.hashCode()).isEqualTo(n3.hashCode()).isEqualTo(n4.hashCode()); ImageName n6 = ImageName.of("alpine");
assertThat(n1).isEqualTo(n1).isEqualTo(n2).isEqualTo(n3).isEqualTo(n4).isNotEqualTo(n5); 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);
} }
} }
/*
* 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");
}
}
...@@ -135,6 +135,16 @@ class BootBuildImageIntegrationTests { ...@@ -135,6 +135,16 @@ class BootBuildImageIntegrationTests {
assertThat(result.getOutput()).containsPattern("Builder lifecycle '.*' failed with status code"); 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() { private void writeMainClass() {
File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example"); File examplePackage = new File(this.gradleBuild.getProjectDir(), "src/main/java/example");
examplePackage.mkdirs(); examplePackage.mkdirs();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment