Skip to content

Commit cad4fd0

Browse files
authored
Merge pull request #2 from aboutcode-org/support-updated-spec
fix: Support updated PackageURL specs
2 parents 03122a7 + 1a4d493 commit cad4fd0

File tree

5 files changed

+84
-42
lines changed

5 files changed

+84
-42
lines changed

src/main/java/com/github/packageurl/PackageURL.java

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,7 @@ public PackageURL(final String purl) throws MalformedPackageURLException {
145145
} else {
146146
this.subpath = null;
147147
}
148-
// qualifiers are optional - check for existence
149-
final String rawQuery = uri.getRawQuery();
150-
if (rawQuery != null && !rawQuery.isEmpty()) {
151-
this.qualifiers = parseQualifiers(rawQuery);
152-
} else {
153-
this.qualifiers = null;
154-
}
148+
155149
// this is the rest of the purl that needs to be parsed
156150
String remainder = uri.getRawPath();
157151
// trim trailing '/'
@@ -169,6 +163,14 @@ public PackageURL(final String purl) throws MalformedPackageURLException {
169163
}
170164
this.type = StringUtil.toLowerCase(validateType(remainder.substring(start, index)));
171165

166+
// qualifiers are optional - check for existence
167+
final String rawQuery = uri.getRawQuery();
168+
if (rawQuery != null && !rawQuery.isEmpty()) {
169+
this.qualifiers = parseQualifiers(this.type, rawQuery);
170+
} else {
171+
this.qualifiers = null;
172+
}
173+
172174
start = index + 1;
173175

174176
// version is optional - check for existence
@@ -183,10 +185,10 @@ public PackageURL(final String purl) throws MalformedPackageURLException {
183185
// The 'remainder' should now consist of an optional namespace and the name
184186
index = remainder.lastIndexOf('/');
185187
if (index <= start) {
186-
this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(start)));
188+
this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(start)), this.qualifiers);
187189
this.namespace = null;
188190
} else {
189-
this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(index + 1)));
191+
this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(index + 1)), this.qualifiers);
190192
remainder = remainder.substring(0, index);
191193
this.namespace = validateNamespace(this.type, parsePath(remainder.substring(start), false));
192194
}
@@ -231,9 +233,9 @@ public PackageURL(
231233
throws MalformedPackageURLException {
232234
this.type = StringUtil.toLowerCase(validateType(requireNonNull(type, "type")));
233235
this.namespace = validateNamespace(this.type, namespace);
234-
this.name = validateName(this.type, requireNonNull(name, "name"));
236+
this.qualifiers = parseQualifiers(this.type, qualifiers);
237+
this.name = validateName(this.type, requireNonNull(name, "name"), this.qualifiers);
235238
this.version = validateVersion(this.type, version);
236-
this.qualifiers = parseQualifiers(qualifiers);
237239
this.subpath = validateSubpath(subpath);
238240
verifyTypeConstraints(this.type, this.namespace, this.name);
239241
}
@@ -394,7 +396,7 @@ private static void validateChars(String value, IntPredicate predicate, String c
394396
return retVal;
395397
}
396398

397-
private static String validateName(final String type, final String value) throws MalformedPackageURLException {
399+
private static String validateName(final String type, final String value, final Map<String,String> qualifiers) throws MalformedPackageURLException {
398400
if (value.isEmpty()) {
399401
throw new MalformedPackageURLException("The PackageURL name specified is invalid");
400402
}
@@ -412,6 +414,9 @@ private static String validateName(final String type, final String value) throws
412414
case StandardTypes.OCI:
413415
temp = StringUtil.toLowerCase(value);
414416
break;
417+
case StandardTypes.MLFLOW:
418+
temp = validateMlflowName(value, qualifiers);
419+
break;
415420
case StandardTypes.PUB:
416421
temp = StringUtil.toLowerCase(value).replaceAll("[^a-z0-9_]", "_");
417422
break;
@@ -425,6 +430,19 @@ private static String validateName(final String type, final String value) throws
425430
return temp;
426431
}
427432

433+
/*
434+
MLflow names are case-sensitive for Azure ML and must be kept as-is,
435+
for Databricks it is case insensitive and must be lowercased.
436+
*/
437+
private static String validateMlflowName(final String name, final Map<String,String> qualifiers){
438+
439+
String value = qualifiers.get("repository_url");
440+
if (value != null && value.toLowerCase().contains("databricks")) {
441+
return StringUtil.toLowerCase(name);
442+
}
443+
return name;
444+
}
445+
428446
private static @Nullable String validateVersion(final String type, final @Nullable String value) {
429447
if (value == null) {
430448
return null;
@@ -440,7 +458,7 @@ private static String validateName(final String type, final String value) throws
440458
}
441459
}
442460

443-
private static @Nullable Map<String, String> validateQualifiers(final @Nullable Map<String, String> values)
461+
private static @Nullable Map<String, String> validateQualifiers(final String type, final @Nullable Map<String, String> values)
444462
throws MalformedPackageURLException {
445463
if (values == null || values.isEmpty()) {
446464
return null;
@@ -451,6 +469,22 @@ private static String validateName(final String type, final String value) throws
451469
validateKey(key);
452470
validateValue(key, entry.getValue());
453471
}
472+
473+
switch (type) {
474+
case StandardTypes.BAZEL:
475+
String defaultRegistry = "https://bcr.bazel.build";
476+
String repoURL = values.get("repository_url");
477+
String normalized = repoURL.toLowerCase();
478+
if (normalized.endsWith("/")) {
479+
normalized = normalized.substring(0, normalized.length() - 1);
480+
}
481+
482+
if (normalized.equals(defaultRegistry)){
483+
values.remove("repository_url");
484+
}
485+
break;
486+
}
487+
454488
return values;
455489
}
456490

@@ -577,7 +611,7 @@ private static void verifyTypeConstraints(String type, @Nullable String namespac
577611
}
578612
}
579613

580-
private static @Nullable Map<String, String> parseQualifiers(final @Nullable Map<String, String> qualifiers)
614+
private static @Nullable Map<String, String> parseQualifiers(final String type, final @Nullable Map<String, String> qualifiers)
581615
throws MalformedPackageURLException {
582616
if (qualifiers == null || qualifiers.isEmpty()) {
583617
return null;
@@ -590,14 +624,14 @@ private static void verifyTypeConstraints(String type, @Nullable String namespac
590624
TreeMap::new,
591625
(map, value) -> map.put(StringUtil.toLowerCase(value.getKey()), value.getValue()),
592626
TreeMap::putAll);
593-
return validateQualifiers(results);
627+
return validateQualifiers(type, results);
594628
} catch (ValidationException ex) {
595629
throw new MalformedPackageURLException(ex.getMessage());
596630
}
597631
}
598632

599633
@SuppressWarnings("StringSplitter") // reason: surprising behavior is okay in this case
600-
private static @Nullable Map<String, String> parseQualifiers(final String encodedString)
634+
private static @Nullable Map<String, String> parseQualifiers(final String type, final String encodedString)
601635
throws MalformedPackageURLException {
602636
try {
603637
final TreeMap<String, String> results = Arrays.stream(encodedString.split("&"))
@@ -615,7 +649,7 @@ private static void verifyTypeConstraints(String type, @Nullable String namespac
615649
}
616650
},
617651
TreeMap::putAll);
618-
return validateQualifiers(results);
652+
return validateQualifiers(type, results);
619653
} catch (ValidationException e) {
620654
throw new MalformedPackageURLException(e);
621655
}
@@ -730,6 +764,12 @@ public static final class StandardTypes {
730764
* @since 2.0.0
731765
*/
732766
public static final String APK = "apk";
767+
/**
768+
* Bazel-based packages.
769+
*
770+
* @since 2.0.0
771+
*/
772+
public static final String BAZEL = "bazel";
733773
/**
734774
* Bitbucket-based packages.
735775
*/

src/main/java/com/github/packageurl/internal/StringUtil.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@
2222
package com.github.packageurl.internal;
2323

2424
import static java.lang.Byte.toUnsignedInt;
25-
26-
import com.github.packageurl.ValidationException;
2725
import java.nio.charset.StandardCharsets;
26+
2827
import org.jspecify.annotations.NonNull;
2928

29+
import com.github.packageurl.ValidationException;
30+
3031
/**
3132
* String utility for validation and encoding.
3233
*
@@ -52,6 +53,13 @@ public final class StringUtil {
5253
UNRESERVED_CHARS['.'] = true;
5354
UNRESERVED_CHARS['_'] = true;
5455
UNRESERVED_CHARS['~'] = true;
56+
57+
/*
58+
According to purl-spec https://github.com/package-url/purl-spec/blob/0c3bc118ac5c001e067ba42fba8501405514f1a9/docs/standard/characters-and-encoding.md
59+
> The following characters must not be percent-encoded:
60+
> - the colon ':', whether used as a Separator Character or otherwise
61+
*/
62+
UNRESERVED_CHARS[':'] = true;
5563
}
5664

5765
private StringUtil() {

src/test/java/com/github/packageurl/PurlSpecRefTest.java

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,38 +25,33 @@
2525

2626
package com.github.packageurl;
2727

28-
import java.net.URL;
29-
30-
import static org.junit.jupiter.api.Assertions.assertEquals;
31-
import static org.junit.jupiter.api.Assertions.assertFalse;
32-
import static org.junit.jupiter.api.Assertions.assertTrue;
33-
3428
import java.io.IOException;
3529
import java.io.InputStream;
30+
import java.net.URL;
3631
import java.nio.file.Files;
37-
import java.nio.file.Paths;
3832
import java.nio.file.Path;
33+
import java.nio.file.Paths;
3934
import java.util.Collections;
4035
import java.util.List;
36+
import java.util.Map;
4137
import java.util.stream.Collectors;
4238
import java.util.stream.Stream;
43-
import com.fasterxml.jackson.databind.ObjectMapper;
44-
45-
import com.fasterxml.jackson.databind.JsonNode;
4639

40+
import static org.junit.jupiter.api.Assertions.assertEquals;
41+
import static org.junit.jupiter.api.Assertions.assertFalse;
42+
import static org.junit.jupiter.api.Assertions.assertTrue;
4743
import org.junit.jupiter.params.ParameterizedTest;
4844
import org.junit.jupiter.params.provider.MethodSource;
4945

5046
import com.fasterxml.jackson.annotation.JsonProperty;
5147
import com.fasterxml.jackson.core.JsonParser;
52-
import com.fasterxml.jackson.core.ObjectCodec;
5348
import com.fasterxml.jackson.core.JsonProcessingException;
54-
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
55-
56-
import com.fasterxml.jackson.databind.JsonDeserializer;
49+
import com.fasterxml.jackson.core.ObjectCodec;
5750
import com.fasterxml.jackson.databind.DeserializationContext;
58-
59-
import java.util.Map;
51+
import com.fasterxml.jackson.databind.JsonDeserializer;
52+
import com.fasterxml.jackson.databind.JsonNode;
53+
import com.fasterxml.jackson.databind.ObjectMapper;
54+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
6055

6156
public class PurlSpecRefTest {
6257

@@ -143,14 +138,13 @@ static Stream<TestCase> collectTestCases() throws Exception {
143138
void runRoundtripTest(TestCase testCase) throws Exception {
144139
String result;
145140
try {
146-
result = new PackageURL(testCase.input.purl).canonicalize().toString();
141+
result = new PackageURL(testCase.input.purl).canonicalize();
147142
} catch (Exception e) {
148143
assertTrue(testCase.expected_failure, "Unexpected failure: " + e.getMessage());
149144
return;
150145
}
151146
assertFalse(testCase.expected_failure, "Expected failure but parsing succeeded");
152-
153-
assertEquals(result, testCase.expected_output.purl);
147+
assertEquals(testCase.expected_output.purl, result);
154148

155149
}
156150

@@ -159,14 +153,14 @@ void runBuildTest(TestCase testCase) throws Exception {
159153
String result;
160154
try {
161155
result = new PackageURL(input.type, input.namespace, input.name, input.version, input.qualifiers,
162-
input.subpath).canonicalize().toString();
156+
input.subpath).canonicalize();
163157
} catch (Exception e) {
164158
assertTrue(testCase.expected_failure, "Unexpected failure: " + e.getMessage());
165159
return;
166160
}
167161

168162
assertFalse(testCase.expected_failure, "Expected failure but build succeeded");
169-
assertEquals(result, testCase.expected_output.purl);
163+
assertEquals(testCase.expected_output.purl, result);
170164
}
171165

172166
void runParseTest(TestCase testCase) throws Exception {

src/test/resources/purl-spec

Submodule purl-spec updated 139 files

src/test/resources/test-suite-data.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
{
8787
"description": "docker uses qualifiers and hash image id as versions",
8888
"purl": "pkg:docker/customer/dockerimage@sha256:244fd47e07d1004f0aed9c?repository_url=gcr.io",
89-
"canonical_purl": "pkg:docker/customer/dockerimage@sha256%3A244fd47e07d1004f0aed9c?repository_url=gcr.io",
89+
"canonical_purl": "pkg:docker/customer/dockerimage@sha256:244fd47e07d1004f0aed9c?repository_url=gcr.io",
9090
"type": "docker",
9191
"namespace": "customer",
9292
"name": "dockerimage",

0 commit comments

Comments
 (0)