Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 57 additions & 17 deletions src/main/java/com/github/packageurl/PackageURL.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 '/'
Expand All @@ -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
Expand All @@ -183,10 +185,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));
}
Expand Down Expand Up @@ -231,9 +233,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.qualifiers = parseQualifiers(this.type, qualifiers);
this.name = validateName(this.type, requireNonNull(name, "name"), this.qualifiers);
this.version = validateVersion(this.type, version);
this.qualifiers = parseQualifiers(qualifiers);
this.subpath = validateSubpath(subpath);
verifyTypeConstraints(this.type, this.namespace, this.name);
}
Expand Down Expand Up @@ -394,7 +396,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<String,String> qualifiers) throws MalformedPackageURLException {
if (value.isEmpty()) {
throw new MalformedPackageURLException("The PackageURL name specified is invalid");
}
Expand All @@ -412,6 +414,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;
Expand All @@ -425,6 +430,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<String,String> 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;
Expand All @@ -440,7 +458,7 @@ private static String validateName(final String type, final String value) throws
}
}

private static @Nullable Map<String, String> validateQualifiers(final @Nullable Map<String, String> values)
private static @Nullable Map<String, String> validateQualifiers(final String type, final @Nullable Map<String, String> values)
throws MalformedPackageURLException {
if (values == null || values.isEmpty()) {
return null;
Expand All @@ -451,6 +469,22 @@ private static String validateName(final String type, final String value) throws
validateKey(key);
validateValue(key, entry.getValue());
}

switch (type) {
case StandardTypes.BAZEL:
String defaultRegistry = "https://bcr.bazel.build";
String repoURL = values.get("repository_url");
String normalized = repoURL.toLowerCase();
if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}

if (normalized.equals(defaultRegistry)){
values.remove("repository_url");
}
break;
}

return values;
}

Expand Down Expand Up @@ -577,7 +611,7 @@ private static void verifyTypeConstraints(String type, @Nullable String namespac
}
}

private static @Nullable Map<String, String> parseQualifiers(final @Nullable Map<String, String> qualifiers)
private static @Nullable Map<String, String> parseQualifiers(final String type, final @Nullable Map<String, String> qualifiers)
throws MalformedPackageURLException {
if (qualifiers == null || qualifiers.isEmpty()) {
return null;
Expand All @@ -590,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<String, String> parseQualifiers(final String encodedString)
private static @Nullable Map<String, String> parseQualifiers(final String type, final String encodedString)
throws MalformedPackageURLException {
try {
final TreeMap<String, String> results = Arrays.stream(encodedString.split("&"))
Expand All @@ -615,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);
}
Expand Down Expand Up @@ -730,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.
*/
Expand Down
12 changes: 10 additions & 2 deletions src/main/java/com/github/packageurl/internal/StringUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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() {
Expand Down
36 changes: 15 additions & 21 deletions src/test/java/com/github/packageurl/PurlSpecRefTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -143,14 +138,13 @@ static Stream<TestCase> 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);

}

Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/test/resources/purl-spec
Submodule purl-spec updated 139 files
2 changes: 1 addition & 1 deletion src/test/resources/test-suite-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading