diff --git a/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpoint.java b/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpoint.java
index 19b925fa..a91e088d 100644
--- a/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpoint.java
+++ b/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpoint.java
@@ -29,9 +29,11 @@
import com.cloudbees.plugins.credentials.common.StandardListBoxModel;
import com.cloudbees.plugins.credentials.domains.DomainRequirement;
import com.cloudbees.plugins.credentials.domains.HostnameRequirement;
-
+import hudson.AbortException;
+import hudson.EnvVars;
import hudson.Extension;
import hudson.FilePath;
+import hudson.Launcher;
import hudson.Util;
import hudson.model.AbstractBuild;
import hudson.model.AbstractDescribableImpl;
@@ -41,17 +43,18 @@
import hudson.model.Run;
import hudson.model.TaskListener;
import hudson.remoting.VirtualChannel;
+import hudson.slaves.WorkspaceList;
+import hudson.util.FormValidation;
import hudson.util.ListBoxModel;
import jenkins.authentication.tokens.api.AuthenticationTokens;
import jenkins.model.Jenkins;
-
+import org.jenkinsci.plugins.docker.commons.tools.DockerTool;
import org.kohsuke.stapler.AncestorInPath;
import org.kohsuke.stapler.DataBoundConstructor;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
@@ -62,12 +65,10 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-import static com.cloudbees.plugins.credentials.CredentialsMatchers.*;
-import hudson.AbortException;
-import hudson.EnvVars;
-import hudson.Launcher;
-import hudson.slaves.WorkspaceList;
-import org.jenkinsci.plugins.docker.commons.tools.DockerTool;
+import static com.cloudbees.plugins.credentials.CredentialsMatchers.allOf;
+import static com.cloudbees.plugins.credentials.CredentialsMatchers.firstOrNull;
+import static com.cloudbees.plugins.credentials.CredentialsMatchers.withId;
+import static org.jenkinsci.plugins.docker.commons.credentials.ImageNameValidator.validateUserAndRepo;
/**
* Encapsulates the endpoint of DockerHub and how to interact with it.
@@ -312,6 +313,12 @@ public String imageName(@Nonnull String userAndRepo) throws IOException {
if (userAndRepo == null) {
throw new IllegalArgumentException("Image name cannot be null.");
}
+
+ final FormValidation validation = validateUserAndRepo(userAndRepo);
+ if (validation.kind != FormValidation.Kind.OK) {
+ throw validation;
+ }
+
if (url == null) {
return userAndRepo;
}
diff --git a/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidator.java b/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidator.java
new file mode 100644
index 00000000..993c253b
--- /dev/null
+++ b/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidator.java
@@ -0,0 +1,225 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2021, CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package org.jenkinsci.plugins.docker.commons.credentials;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.util.FormValidation;
+import org.apache.commons.lang.StringUtils;
+
+import javax.annotation.CheckForNull;
+import java.util.Arrays;
+import java.util.regex.Pattern;
+
+public class ImageNameValidator {
+
+ private static /*almost final*/ boolean SKIP = Boolean.getBoolean(ImageNameValidator.class.getName() + ".SKIP");
+
+ /**
+ * If the validation is set to be skipped.
+ *
+ * I.e. the system property org.jenkinsci.plugins.docker.commons.credentials.ImageNameValidator.SKIP
+ * is set to true
.
+ * When this is se to true {@link #validateName(String)}, {@link #validateTag(String)} and {@link #validateUserAndRepo(String)}
+ * returns {@link FormValidation#ok()} immediately without performing the validation.
+ *
+ * @return true if validation is skipped.
+ */
+ public static boolean skipped() {
+ return SKIP;
+ }
+
+ /**
+ * Splits a repository id namespace/name into it's three components (repo/namespace[/*],name,tag)
+ *
+ * @param userAndRepo the repository ID namespace/name (ie. "jenkinsci/workflow-demo:latest").
+ * The namespace can have more than one path element.
+ * @return an array where position 0 is the namespace, 1 is the name and 2 is the tag.
+ * Any position could be null
+ */
+ public static @NonNull String[] splitUserAndRepo(@NonNull String userAndRepo) {
+ String[] args = new String[3];
+ if (StringUtils.isEmpty(userAndRepo)) {
+ return args;
+ }
+ int slashIdx = userAndRepo.lastIndexOf('/');
+ int tagIdx = userAndRepo.lastIndexOf(':');
+ if (tagIdx == -1 && slashIdx == -1) {
+ args[1] = userAndRepo;
+ } else if (tagIdx < slashIdx) {
+ //something:port/something or something/something
+ args[0] = userAndRepo.substring(0, slashIdx);
+ args[1] = userAndRepo.substring(slashIdx + 1);
+ } else {
+ if (slashIdx != -1) {
+ args[0] = userAndRepo.substring(0, slashIdx);
+ args[1] = userAndRepo.substring(slashIdx + 1);
+ }
+ if (tagIdx > 0) {
+ int start = slashIdx > 0 ? slashIdx + 1 : 0;
+ args[1] = userAndRepo.substring(start, tagIdx);
+ if (tagIdx < userAndRepo.length() - 1) {
+ args[2] = userAndRepo.substring(tagIdx + 1);
+ }
+ }
+ }
+ return args;
+ }
+
+ /**
+ * Validates the string as [registry/repo/]name[:tag]
+ * @param userAndRepo the image id
+ * @return if it is valid or not, or OK if set to {@link #SKIP}.
+ *
+ * @see #VALID_NAME_COMPONENT
+ * @see #VALID_TAG
+ */
+ public static @NonNull FormValidation validateUserAndRepo(@NonNull String userAndRepo) {
+ if (SKIP) {
+ return FormValidation.ok();
+ }
+ final String[] args = splitUserAndRepo(userAndRepo);
+ if (StringUtils.isBlank(args[0]) && StringUtils.isBlank(args[1]) && StringUtils.isBlank(args[2])) {
+ return FormValidation.error("Bad imageName format: %s", userAndRepo);
+ }
+ final FormValidation name = validateName(args[1]);
+ final FormValidation tag = validateTag(args[2]);
+ if (name.kind == FormValidation.Kind.OK && tag.kind == FormValidation.Kind.OK) {
+ return FormValidation.ok();
+ }
+ if (name.kind == FormValidation.Kind.OK) {
+ return tag;
+ }
+ if (tag.kind == FormValidation.Kind.OK) {
+ return name;
+ }
+ return FormValidation.aggregate(Arrays.asList(name, tag));
+ }
+
+ /**
+ * Calls {@link #validateUserAndRepo(String)} and if the result is not OK throws it as an exception.
+ *
+ * @param userAndRepo the image id
+ * @throws FormValidation if not OK
+ */
+ public static void checkUserAndRepo(@NonNull String userAndRepo) throws FormValidation {
+ final FormValidation validation = validateUserAndRepo(userAndRepo);
+ if (validation.kind != FormValidation.Kind.OK) {
+ throw validation;
+ }
+ }
+
+ /**
+ * A tag name must be valid ASCII and may contain
+ * lowercase and uppercase letters, digits, underscores, periods and dashes.
+ * A tag name may not start with a period or a dash and may contain a maximum of 128 characters.
+ *
+ * @see docker tag
+ */
+ public static final Pattern VALID_TAG = Pattern.compile("^[a-zA-Z0-9_]([a-zA-Z0-9_.-]){0,127}");
+
+
+ /**
+ * Validates a tag is following the rules.
+ *
+ * If the tag is null or the empty string it is considered valid.
+ *
+ * @param tag the tag to validate.
+ * @return the validation result
+ * @see #VALID_TAG
+ */
+ public static @NonNull FormValidation validateTag(@CheckForNull String tag) {
+ if (SKIP) {
+ return FormValidation.ok();
+ }
+ if (StringUtils.isEmpty(tag)) {
+ return FormValidation.ok();
+ }
+ if (tag.length() > 128) {
+ return FormValidation.error("Tag length > 128");
+ }
+ if (VALID_TAG.matcher(tag).matches()) {
+ return FormValidation.ok();
+ } else {
+ return FormValidation.error("Tag must follow the pattern '%s'", VALID_TAG.pattern());
+ }
+ }
+
+ /**
+ * Calls {@link #validateTag(String)} and if not OK throws the exception.
+ *
+ * @param tag the tag
+ * @throws FormValidation if not OK
+ */
+ public static void checkTag(@CheckForNull String tag) throws FormValidation {
+ final FormValidation validation = validateTag(tag);
+ if (validation.kind != FormValidation.Kind.OK) {
+ throw validation;
+ }
+ }
+
+ /**
+ * Name components may contain lowercase letters, digits and separators.
+ * A separator is defined as a period, one or two underscores, or one or more dashes.
+ * A name component may not start or end with a separator.
+ *
+ * @see docker tag
+ */
+ public static final Pattern VALID_NAME_COMPONENT = Pattern.compile("^[a-zA-Z0-9]+((\\.|_|__|-+)[a-zA-Z0-9]+)*$");
+
+ /**
+ * Validates a docker image name that it is following the rules as a single name component.
+ *
+ * If the name is null or the empty string it is not considered valid.
+ *
+ * @param name the name
+ * @return the validation result
+ * @see #VALID_NAME_COMPONENT
+ */
+ public static @NonNull FormValidation validateName(@CheckForNull String name) {
+ if (SKIP) {
+ return FormValidation.ok();
+ }
+ if (StringUtils.isEmpty(name)) {
+ return FormValidation.error("Missing name.");
+ }
+ if (VALID_NAME_COMPONENT.matcher(name).matches()) {
+ return FormValidation.ok();
+ } else {
+ return FormValidation.error("Name must follow the pattern '%s'", VALID_NAME_COMPONENT.pattern());
+ }
+ }
+
+ /**
+ * Calls {@link #validateName(String)} and if not OK throws the exception.
+ *
+ * @param name the name
+ * @throws FormValidation if not OK
+ */
+ public static void checkName(String name) throws FormValidation {
+ final FormValidation validation = validateName(name);
+ if (validation.kind != FormValidation.Kind.OK) {
+ throw validation;
+ }
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpointTest.java b/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpointTest.java
index ad9099d1..a79ad264 100644
--- a/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpointTest.java
+++ b/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpointTest.java
@@ -89,6 +89,8 @@ public void testParseWithTags() throws Exception {
public void testParseFullyQualifiedImageName() throws Exception {
assertEquals("private-repo:5000/test-image", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("private-repo:5000/test-image"));
assertEquals("private-repo:5000/test-image", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("test-image"));
+ assertEquals("private-repo:5000/test-image:dev", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("private-repo:5000/test-image:dev"));
+ assertEquals("private-repo:5000/test-image:dev", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("test-image:dev"));
}
@Issue("JENKINS-39181")
diff --git a/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidatorTest.java b/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidatorTest.java
new file mode 100644
index 00000000..1ae53aee
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidatorTest.java
@@ -0,0 +1,58 @@
+package org.jenkinsci.plugins.docker.commons.credentials;
+
+import hudson.util.FormValidation;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests various inputs to {@link ImageNameValidator#validateUserAndRepo(String)}.
+ */
+@RunWith(Parameterized.class)
+public class ImageNameValidatorTest {
+
+ @Parameterized.Parameters(name = "{index}:{0}") public static Object[][] data(){
+ return new Object[][] {
+ {"jenkinsci/workflow-demo", FormValidation.Kind.OK},
+ {"docker:80/jenkinsci/workflow-demo", FormValidation.Kind.OK},
+ {"jenkinsci/workflow-demo:latest", FormValidation.Kind.OK},
+ {"docker:80/jenkinsci/workflow-demo:latest", FormValidation.Kind.OK},
+ {"workflow-demo:latest", FormValidation.Kind.OK},
+ {"workflow-demo", FormValidation.Kind.OK},
+ {":tag", FormValidation.Kind.ERROR},
+ {"name:tag", FormValidation.Kind.OK},
+ {"name:.tag", FormValidation.Kind.ERROR},
+ {"name:-tag", FormValidation.Kind.ERROR},
+ {"name:.tag.", FormValidation.Kind.ERROR},
+ {"name:tag.", FormValidation.Kind.OK},
+ {"name:tag-", FormValidation.Kind.OK},
+ {"_name:tag", FormValidation.Kind.ERROR},
+ {"na___me:tag", FormValidation.Kind.ERROR},
+ {"na__me:tag", FormValidation.Kind.OK},
+ {"name:tag\necho hello", FormValidation.Kind.ERROR},
+ {"name\necho hello:tag", FormValidation.Kind.ERROR},
+ {"name:tag$BUILD_NUMBER", FormValidation.Kind.ERROR},
+ {"name$BUILD_NUMBER:tag", FormValidation.Kind.ERROR},
+ {null, FormValidation.Kind.ERROR},
+ {"", FormValidation.Kind.ERROR},
+ {":", FormValidation.Kind.ERROR},
+ {" ", FormValidation.Kind.ERROR},
+
+ };
+ }
+
+ private final String userAndRepo;
+ private final FormValidation.Kind expected;
+
+ public ImageNameValidatorTest(final String userAndRepo, final FormValidation.Kind expected) {
+ this.userAndRepo = userAndRepo;
+ this.expected = expected;
+ }
+
+ @Test
+ public void test() {
+ assertSame(expected, ImageNameValidator.validateUserAndRepo(userAndRepo).kind);
+ }
+}
\ No newline at end of file