From 59e6c55de4a581a0fbc4a8515fb91972b72bd908 Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Tue, 2 Dec 2025 15:01:06 +0530 Subject: [PATCH 1/5] Colon should not be encoded in all cases - https://github.com/package-url/purl-spec/blob/0c3bc118ac5c001e067ba42fba8501405514f1a9/docs/standard/characters-and-encoding.md Signed-off-by: Keshav Priyadarshi --- .../com/github/packageurl/internal/StringUtil.java | 12 ++++++++++-- src/test/resources/test-suite-data.json | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/github/packageurl/internal/StringUtil.java b/src/main/java/com/github/packageurl/internal/StringUtil.java index 5225ce1d..ae6566b9 100644 --- a/src/main/java/com/github/packageurl/internal/StringUtil.java +++ b/src/main/java/com/github/packageurl/internal/StringUtil.java @@ -22,11 +22,12 @@ package com.github.packageurl.internal; import static java.lang.Byte.toUnsignedInt; - -import com.github.packageurl.ValidationException; import java.nio.charset.StandardCharsets; + import org.jspecify.annotations.NonNull; +import com.github.packageurl.ValidationException; + /** * String utility for validation and encoding. * @@ -52,6 +53,13 @@ public final class StringUtil { UNRESERVED_CHARS['.'] = true; UNRESERVED_CHARS['_'] = true; UNRESERVED_CHARS['~'] = true; + + /* + According to purl-spec https://github.com/package-url/purl-spec/blob/0c3bc118ac5c001e067ba42fba8501405514f1a9/docs/standard/characters-and-encoding.md + > The following characters must not be percent-encoded: + > - the colon ':', whether used as a Separator Character or otherwise + */ + UNRESERVED_CHARS[':'] = true; } private StringUtil() { diff --git a/src/test/resources/test-suite-data.json b/src/test/resources/test-suite-data.json index 2eb9b3b9..b9b389dc 100644 --- a/src/test/resources/test-suite-data.json +++ b/src/test/resources/test-suite-data.json @@ -86,7 +86,7 @@ { "description": "docker uses qualifiers and hash image id as versions", "purl": "pkg:docker/customer/dockerimage@sha256:244fd47e07d1004f0aed9c?repository_url=gcr.io", - "canonical_purl": "pkg:docker/customer/dockerimage@sha256%3A244fd47e07d1004f0aed9c?repository_url=gcr.io", + "canonical_purl": "pkg:docker/customer/dockerimage@sha256:244fd47e07d1004f0aed9c?repository_url=gcr.io", "type": "docker", "namespace": "customer", "name": "dockerimage", From 2e158026a480e3e78e9f8963e7a1e55131e855b3 Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Tue, 2 Dec 2025 15:03:57 +0530 Subject: [PATCH 2/5] Use the latest test fixture Signed-off-by: Keshav Priyadarshi --- src/test/resources/purl-spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/purl-spec b/src/test/resources/purl-spec index 414fef48..0c3bc118 160000 --- a/src/test/resources/purl-spec +++ b/src/test/resources/purl-spec @@ -1 +1 @@ -Subproject commit 414fef487025046691af67f70dfa8677139df92d +Subproject commit 0c3bc118ac5c001e067ba42fba8501405514f1a9 From 3bb3c7ca747a684b8177bb20331131db80adc7ff Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Tue, 2 Dec 2025 15:21:19 +0530 Subject: [PATCH 3/5] Fix order of assertions in tests Signed-off-by: Keshav Priyadarshi --- .../github/packageurl/PurlSpecRefTest.java | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/src/test/java/com/github/packageurl/PurlSpecRefTest.java b/src/test/java/com/github/packageurl/PurlSpecRefTest.java index e27bcb95..01c84b5a 100644 --- a/src/test/java/com/github/packageurl/PurlSpecRefTest.java +++ b/src/test/java/com/github/packageurl/PurlSpecRefTest.java @@ -25,38 +25,33 @@ package com.github.packageurl; -import java.net.URL; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - import java.io.IOException; import java.io.InputStream; +import java.net.URL; import java.nio.file.Files; -import java.nio.file.Paths; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; -import com.fasterxml.jackson.databind.ObjectMapper; - -import com.fasterxml.jackson.databind.JsonNode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.ObjectCodec; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; - -import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.core.ObjectCodec; import com.fasterxml.jackson.databind.DeserializationContext; - -import java.util.Map; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; public class PurlSpecRefTest { @@ -143,14 +138,13 @@ static Stream collectTestCases() throws Exception { void runRoundtripTest(TestCase testCase) throws Exception { String result; try { - result = new PackageURL(testCase.input.purl).canonicalize().toString(); + result = new PackageURL(testCase.input.purl).canonicalize(); } catch (Exception e) { assertTrue(testCase.expected_failure, "Unexpected failure: " + e.getMessage()); return; } assertFalse(testCase.expected_failure, "Expected failure but parsing succeeded"); - - assertEquals(result, testCase.expected_output.purl); + assertEquals(testCase.expected_output.purl, result); } @@ -159,14 +153,14 @@ void runBuildTest(TestCase testCase) throws Exception { String result; try { result = new PackageURL(input.type, input.namespace, input.name, input.version, input.qualifiers, - input.subpath).canonicalize().toString(); + input.subpath).canonicalize(); } catch (Exception e) { assertTrue(testCase.expected_failure, "Unexpected failure: " + e.getMessage()); return; } assertFalse(testCase.expected_failure, "Expected failure but build succeeded"); - assertEquals(result, testCase.expected_output.purl); + assertEquals(testCase.expected_output.purl, result); } void runParseTest(TestCase testCase) throws Exception { From babdf761462b5d55d9625f1a6d9aa1cd7b70d49b Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Tue, 2 Dec 2025 15:23:17 +0530 Subject: [PATCH 4/5] Validate MLflow PURL name Signed-off-by: Keshav Priyadarshi --- .../com/github/packageurl/PackageURL.java | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/github/packageurl/PackageURL.java b/src/main/java/com/github/packageurl/PackageURL.java index a474651f..09e35fb8 100644 --- a/src/main/java/com/github/packageurl/PackageURL.java +++ b/src/main/java/com/github/packageurl/PackageURL.java @@ -183,10 +183,10 @@ public PackageURL(final String purl) throws MalformedPackageURLException { // The 'remainder' should now consist of an optional namespace and the name index = remainder.lastIndexOf('/'); if (index <= start) { - this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(start))); + this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(start)), this.qualifiers); this.namespace = null; } else { - this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(index + 1))); + this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(index + 1)), this.qualifiers); remainder = remainder.substring(0, index); this.namespace = validateNamespace(this.type, parsePath(remainder.substring(start), false)); } @@ -231,9 +231,9 @@ public PackageURL( throws MalformedPackageURLException { this.type = StringUtil.toLowerCase(validateType(requireNonNull(type, "type"))); this.namespace = validateNamespace(this.type, namespace); - this.name = validateName(this.type, requireNonNull(name, "name")); - this.version = validateVersion(this.type, version); this.qualifiers = parseQualifiers(qualifiers); + this.name = validateName(this.type, requireNonNull(name, "name"), this.qualifiers); + this.version = validateVersion(this.type, version); this.subpath = validateSubpath(subpath); verifyTypeConstraints(this.type, this.namespace, this.name); } @@ -394,7 +394,7 @@ private static void validateChars(String value, IntPredicate predicate, String c return retVal; } - private static String validateName(final String type, final String value) throws MalformedPackageURLException { + private static String validateName(final String type, final String value, final Map qualifiers) throws MalformedPackageURLException { if (value.isEmpty()) { throw new MalformedPackageURLException("The PackageURL name specified is invalid"); } @@ -412,6 +412,9 @@ private static String validateName(final String type, final String value) throws case StandardTypes.OCI: temp = StringUtil.toLowerCase(value); break; + case StandardTypes.MLFLOW: + temp = validateMlflowName(value, qualifiers); + break; case StandardTypes.PUB: temp = StringUtil.toLowerCase(value).replaceAll("[^a-z0-9_]", "_"); break; @@ -425,6 +428,19 @@ private static String validateName(final String type, final String value) throws return temp; } + /* + MLflow names are case-sensitive for Azure ML and must be kept as-is, + for Databricks it is case insensitive and must be lowercased. + */ + private static String validateMlflowName(final String name, final Map qualifiers){ + + String value = qualifiers.get("repository_url"); + if (value != null && value.toLowerCase().contains("databricks")) { + return StringUtil.toLowerCase(name); + } + return name; + } + private static @Nullable String validateVersion(final String type, final @Nullable String value) { if (value == null) { return null; From 1a4d4936ee943875a03c96bd5caecaba234f7af0 Mon Sep 17 00:00:00 2001 From: Keshav Priyadarshi Date: Tue, 2 Dec 2025 18:24:18 +0530 Subject: [PATCH 5/5] Add support for Bazel PackageURL Signed-off-by: Keshav Priyadarshi --- .../com/github/packageurl/PackageURL.java | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/github/packageurl/PackageURL.java b/src/main/java/com/github/packageurl/PackageURL.java index 09e35fb8..1b312790 100644 --- a/src/main/java/com/github/packageurl/PackageURL.java +++ b/src/main/java/com/github/packageurl/PackageURL.java @@ -145,13 +145,7 @@ public PackageURL(final String purl) throws MalformedPackageURLException { } else { this.subpath = null; } - // qualifiers are optional - check for existence - final String rawQuery = uri.getRawQuery(); - if (rawQuery != null && !rawQuery.isEmpty()) { - this.qualifiers = parseQualifiers(rawQuery); - } else { - this.qualifiers = null; - } + // this is the rest of the purl that needs to be parsed String remainder = uri.getRawPath(); // trim trailing '/' @@ -169,6 +163,14 @@ public PackageURL(final String purl) throws MalformedPackageURLException { } this.type = StringUtil.toLowerCase(validateType(remainder.substring(start, index))); + // qualifiers are optional - check for existence + final String rawQuery = uri.getRawQuery(); + if (rawQuery != null && !rawQuery.isEmpty()) { + this.qualifiers = parseQualifiers(this.type, rawQuery); + } else { + this.qualifiers = null; + } + start = index + 1; // version is optional - check for existence @@ -231,7 +233,7 @@ public PackageURL( throws MalformedPackageURLException { this.type = StringUtil.toLowerCase(validateType(requireNonNull(type, "type"))); this.namespace = validateNamespace(this.type, namespace); - this.qualifiers = parseQualifiers(qualifiers); + this.qualifiers = parseQualifiers(this.type, qualifiers); this.name = validateName(this.type, requireNonNull(name, "name"), this.qualifiers); this.version = validateVersion(this.type, version); this.subpath = validateSubpath(subpath); @@ -456,7 +458,7 @@ private static String validateMlflowName(final String name, final Map validateQualifiers(final @Nullable Map values) + private static @Nullable Map validateQualifiers(final String type, final @Nullable Map values) throws MalformedPackageURLException { if (values == null || values.isEmpty()) { return null; @@ -467,6 +469,22 @@ private static String validateMlflowName(final String name, final Map parseQualifiers(final @Nullable Map qualifiers) + private static @Nullable Map parseQualifiers(final String type, final @Nullable Map qualifiers) throws MalformedPackageURLException { if (qualifiers == null || qualifiers.isEmpty()) { return null; @@ -606,14 +624,14 @@ private static void verifyTypeConstraints(String type, @Nullable String namespac TreeMap::new, (map, value) -> map.put(StringUtil.toLowerCase(value.getKey()), value.getValue()), TreeMap::putAll); - return validateQualifiers(results); + return validateQualifiers(type, results); } catch (ValidationException ex) { throw new MalformedPackageURLException(ex.getMessage()); } } @SuppressWarnings("StringSplitter") // reason: surprising behavior is okay in this case - private static @Nullable Map parseQualifiers(final String encodedString) + private static @Nullable Map parseQualifiers(final String type, final String encodedString) throws MalformedPackageURLException { try { final TreeMap results = Arrays.stream(encodedString.split("&")) @@ -631,7 +649,7 @@ private static void verifyTypeConstraints(String type, @Nullable String namespac } }, TreeMap::putAll); - return validateQualifiers(results); + return validateQualifiers(type, results); } catch (ValidationException e) { throw new MalformedPackageURLException(e); } @@ -746,6 +764,12 @@ public static final class StandardTypes { * @since 2.0.0 */ public static final String APK = "apk"; + /** + * Bazel-based packages. + * + * @since 2.0.0 + */ + public static final String BAZEL = "bazel"; /** * Bitbucket-based packages. */