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 index 993c253..634066f 100644 --- a/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidator.java +++ b/src/main/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidator.java @@ -50,21 +50,22 @@ public static boolean skipped() { } /** - * Splits a repository id namespace/name into it's three components (repo/namespace[/*],name,tag) + * Splits a repository id namespace/name into it's four components (repo/namespace[/*],name,tag, digest) * * @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. + * @return an array where position 0 is the namespace, 1 is the name and 2 is the tag and 3 is the digest. * Any position could be null */ public static @NonNull String[] splitUserAndRepo(@NonNull String userAndRepo) { - String[] args = new String[3]; + String[] args = new String[4]; if (StringUtils.isEmpty(userAndRepo)) { return args; } int slashIdx = userAndRepo.lastIndexOf('/'); int tagIdx = userAndRepo.lastIndexOf(':'); - if (tagIdx == -1 && slashIdx == -1) { + int digestIdx = userAndRepo.lastIndexOf('@'); + if (tagIdx == -1 && slashIdx == -1 && digestIdx == -1) { args[1] = userAndRepo; } else if (tagIdx < slashIdx) { //something:port/something or something/something @@ -75,12 +76,20 @@ public static boolean skipped() { args[0] = userAndRepo.substring(0, slashIdx); args[1] = userAndRepo.substring(slashIdx + 1); } - if (tagIdx > 0) { + if (digestIdx > 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); + String name = userAndRepo.substring(start, digestIdx); + args[1] = name; + tagIdx = name.lastIndexOf(':'); + if (tagIdx > 0) { + args[1] = name.substring(0, tagIdx); + args[2] = name.substring(tagIdx); } + args[3] = userAndRepo.substring(digestIdx); + } else if (tagIdx > 0) { + int start = slashIdx > 0 ? slashIdx + 1 : 0; + args[1] = userAndRepo.substring(start, tagIdx); + args[2] = userAndRepo.substring(tagIdx); } } return args; @@ -99,21 +108,27 @@ public static boolean skipped() { return FormValidation.ok(); } final String[] args = splitUserAndRepo(userAndRepo); - if (StringUtils.isBlank(args[0]) && StringUtils.isBlank(args[1]) && StringUtils.isBlank(args[2])) { + if (StringUtils.isBlank(args[0]) && StringUtils.isBlank(args[1]) && StringUtils.isBlank(args[2]) + && StringUtils.isBlank(args[3])) { 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) { + final FormValidation digest = validateDigest(args[3]); + if (name.kind == FormValidation.Kind.OK && tag.kind == FormValidation.Kind.OK + && digest.kind == FormValidation.Kind.OK) { return FormValidation.ok(); } - if (name.kind == FormValidation.Kind.OK) { + if (name.kind != FormValidation.Kind.OK ) { + return name; + } + if (tag.kind != FormValidation.Kind.OK) { return tag; } - if (tag.kind == FormValidation.Kind.OK) { - return name; + if (digest.kind != FormValidation.Kind.OK) { + return digest; } - return FormValidation.aggregate(Arrays.asList(name, tag)); + return FormValidation.aggregate(Arrays.asList(name, tag, digest)); } /** @@ -129,6 +144,71 @@ public static void checkUserAndRepo(@NonNull String userAndRepo) throws FormVali } } + /** + * A content digest specified by open container spec. + * + * @see Content Digests + * OCI Digests + */ + public static final Pattern VALID_DIGEST = Pattern.compile("^@[a-z0-9]+([+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$"); + + /** + * A SHA-256 content digest specified by open container spec. + * + * @see Content Digests + * OCI Digests + */ + public static final Pattern VALID_DIGEST_SHA256 = Pattern.compile("^@sha256:[a-z0-9]{64}$"); + + /** + * A SHA-512 content digest specified by open container spec. + * + * @see Content Digests + * OCI Digests + */ + public static final Pattern VALID_DIGEST_SHA512 = Pattern.compile("^@sha512:[a-z0-9]{128}$"); + + /** + * Validates a digest is following the rules. + * + * If the tag is null or the empty string it is considered valid. + * + * @param digest the digest to validate. + * @return the validation result + * @see #VALID_DIGEST + */ + public static @NonNull FormValidation validateDigest(@CheckForNull String digest) { + if (SKIP) { + return FormValidation.ok(); + } + if (StringUtils.isEmpty(digest)) { + return FormValidation.ok(); + } + if (digest.startsWith("@sha256")) { + if (digest.length() != 72) { + return FormValidation.error("Digest length != 72"); + } + if (!VALID_DIGEST_SHA256.matcher(digest).matches()) { + return FormValidation.error("Digest must follow the pattern '%s' for sha-256 algorithm", VALID_DIGEST_SHA256.pattern()); + } + return FormValidation.ok(); + } + if (digest.startsWith("@sha512")) { + if (digest.length() != 136) { + return FormValidation.error("Digest length != 136"); + } + if (!VALID_DIGEST_SHA512.matcher(digest).matches()) { + return FormValidation.error("Digest must follow the pattern '%s' for sha-512 algorithm", VALID_DIGEST_SHA512.pattern()); + } + return FormValidation.ok(); + } + if (VALID_DIGEST.matcher(digest).matches()) { + return FormValidation.ok(); + } else { + return FormValidation.error("Digest must follow the pattern '%s'", VALID_DIGEST.pattern()); + } + } + /** * A tag name must be valid ASCII and may contain * lowercase and uppercase letters, digits, underscores, periods and dashes. @@ -136,7 +216,7 @@ public static void checkUserAndRepo(@NonNull String userAndRepo) throws FormVali * * @see docker tag */ - public static final Pattern VALID_TAG = Pattern.compile("^[a-zA-Z0-9_]([a-zA-Z0-9_.-]){0,127}"); + public static final Pattern VALID_TAG = Pattern.compile("^:[a-zA-Z0-9_]([a-zA-Z0-9_.-]){0,127}"); /** 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 index 1ae53ae..36f8304 100644 --- a/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidatorTest.java +++ b/src/test/java/org/jenkinsci/plugins/docker/commons/credentials/ImageNameValidatorTest.java @@ -15,30 +15,49 @@ 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}, + {"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}, + {"jenkinsci/workflow-demo@", FormValidation.Kind.ERROR}, + {"workflow-demo:latest", FormValidation.Kind.OK}, + {"workflow-demo", FormValidation.Kind.OK}, + {"workflow-demo:latest@sha256:56930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb750", FormValidation.Kind.OK}, + {"workflow-demo:latest@sha256:56930391cf0e1be83108422bbef43001650cfb75f64b", FormValidation.Kind.ERROR}, + {"workflow-demo@sha256:56930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb750", FormValidation.Kind.OK}, + {"workflow-demo@sha256:56930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdB750", FormValidation.Kind.ERROR}, + {"workflow-demo:", FormValidation.Kind.ERROR}, + {"workflow-demo:latest@", FormValidation.Kind.ERROR}, + {"workflow-demo@", FormValidation.Kind.ERROR}, + {"jenkinsci/workflow-demo@sha256:56930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb750", FormValidation.Kind.OK}, + {"docker:80/jenkinsci/workflow-demo@sha256:56930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb750", FormValidation.Kind.OK}, + {"docker:80/jenkinsci/workflow-demo:latest@sha256:56930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb750", FormValidation.Kind.OK}, + {"docker:80/jenkinsci/workflow-demo:latest@sha1:0123456789abcdef", FormValidation.Kind.OK}, + {"docker:80/jenkinsci/workflow-demo:latest@sha1:", FormValidation.Kind.ERROR}, + {"docker:80/jenkinsci/workflow-demo@", FormValidation.Kind.ERROR}, + {"docker:80/jenkinsci/workflow-demo:latest@", FormValidation.Kind.ERROR}, + {":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}, + + {"a@sha512:56930391cf0e1be83108422bbef43001650cfb75f64b", FormValidation.Kind.ERROR}, + {"a@sha512:56930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb75056930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb750", FormValidation.Kind.OK}, + {"a@sha512:B6930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb75056930391cf0e1be83108422bbef43001650cfb75f64b3429928f0c5986fdb750", FormValidation.Kind.ERROR} }; } @@ -53,6 +72,7 @@ public ImageNameValidatorTest(final String userAndRepo, final FormValidation.Kin @Test public void test() { - assertSame(expected, ImageNameValidator.validateUserAndRepo(userAndRepo).kind); + FormValidation res = ImageNameValidator.validateUserAndRepo(userAndRepo); + assertSame(userAndRepo + " : " + res.getMessage(), expected, res.kind); } } \ No newline at end of file