run-benchmark
diff --git a/purl-spec b/purl-spec
new file mode 160000
index 00000000..65eeef85
--- /dev/null
+++ b/purl-spec
@@ -0,0 +1 @@
+Subproject commit 65eeef85de5637ffed26695ffd753202b6a4d656
diff --git a/src/main/java/com/github/packageurl/internal/StringUtil.java b/src/main/java/com/github/packageurl/internal/StringUtil.java
index 5225ce1d..687e5dc2 100644
--- a/src/main/java/com/github/packageurl/internal/StringUtil.java
+++ b/src/main/java/com/github/packageurl/internal/StringUtil.java
@@ -52,6 +52,7 @@ public final class StringUtil {
UNRESERVED_CHARS['.'] = true;
UNRESERVED_CHARS['_'] = true;
UNRESERVED_CHARS['~'] = true;
+ UNRESERVED_CHARS[':'] = true;
}
private StringUtil() {
diff --git a/src/test/java/com/github/packageurl/PurlSpecConformanceTest.java b/src/test/java/com/github/packageurl/PurlSpecConformanceTest.java
new file mode 100644
index 00000000..5f9b71aa
--- /dev/null
+++ b/src/test/java/com/github/packageurl/PurlSpecConformanceTest.java
@@ -0,0 +1,336 @@
+/*
+ * MIT License
+ *
+ * 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 com.github.packageurl;
+
+import static com.github.packageurl.PackageURLBuilder.aPackageURL;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;
+import static org.junit.jupiter.api.DynamicTest.dynamicTest;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.json.JSONTokener;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.DynamicNode;
+import org.junit.jupiter.api.TestFactory;
+
+/**
+ * Runs the conformance suite of the PURL specification.
+ *
+ * Only the {@code required} test group is enforced.
+ * {@code recommended} covers remediation of non-canonical input,
+ * which this implementation does not offer as a distinct mode.
+ *
+ * {@code required} tests that do not pass yet are listed in {@code purl-conformance-known-gaps.json}.
+ * To regenerate the file, run:
+ *
mvn test -Dtest=PurlSpecConformanceTest -Dpurl.conformance.recordGaps=true
+ *
+ * @see Test suite
+ */
+class PurlSpecConformanceTest {
+
+ private static final Path SPEC_DIR = Paths.get(System.getProperty("purl.spec.dir", "purl-spec"));
+ private static final String KNOWN_GAPS_RESOURCE = "/purl-conformance-known-gaps.json";
+ private static final Path KNOWN_GAPS_FILE =
+ Paths.get("src", "test", "resources").resolve(KNOWN_GAPS_RESOURCE.substring(1));
+ private static final String TEST_GROUP_REQUIRED = "required";
+ private static final String[] PURL_COMPONENTS = {"type", "namespace", "name", "version", "subpath", "qualifiers"};
+ private static final boolean RECORD_GAPS = Boolean.getBoolean("purl.conformance.recordGaps");
+ private static final Set KNOWN_GAP_KEYS = loadKnownGaps();
+ private static final Map RECORDED_GAPS = Collections.synchronizedMap(new TreeMap<>());
+
+ @TestFactory
+ Stream conformance() throws IOException {
+ if (!Files.isDirectory(SPEC_DIR)) {
+ throw new IllegalStateException(
+ "purl-spec not found at " + SPEC_DIR.toAbsolutePath() + "; run: git submodule update --init");
+ }
+
+ final List containers;
+ try (Stream paths = Files.walk(SPEC_DIR.resolve("tests"))) {
+ containers = paths.filter(path -> path.toString().endsWith(".json"))
+ .sorted()
+ .map(PurlSpecConformanceTest::toContainer)
+ .collect(Collectors.toList());
+ }
+ if (containers.isEmpty()) {
+ throw new IllegalStateException("No fixtures below " + SPEC_DIR.resolve("tests"));
+ }
+
+ return containers.stream();
+ }
+
+ @AfterAll
+ static void writeRecordedGaps() throws IOException {
+ if (!RECORD_GAPS) {
+ return;
+ }
+
+ final JSONArray gaps = new JSONArray();
+ for (JSONObject recorded : RECORDED_GAPS.values()) {
+ gaps.put(new JSONObject()
+ .put("test_type", recorded.optString("test_type"))
+ .put("description", recorded.optString("description"))
+ .put("input", normalizedInput(recorded)));
+ }
+
+ if (!Files.isDirectory(KNOWN_GAPS_FILE.toAbsolutePath().getParent())) {
+ throw new IOException("Expected test resources at "
+ + KNOWN_GAPS_FILE.toAbsolutePath().getParent() + "; run from the module directory");
+ }
+ Files.write(
+ KNOWN_GAPS_FILE,
+ (new JSONObject().put("gaps", gaps).toString(2) + "\n").getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static DynamicNode toContainer(Path file) {
+ final JSONArray tests = readJson(file).optJSONArray("tests");
+ final Map> byGroup = new TreeMap<>();
+
+ for (int i = 0; tests != null && i < tests.length(); i++) {
+ final JSONObject test = tests.getJSONObject(i);
+ final String group = test.optString("test_group", TEST_GROUP_REQUIRED);
+ byGroup.computeIfAbsent(group, key -> new ArrayList<>())
+ .add(dynamicTest(
+ "[" + test.optString("test_type") + "] " + test.optString("description"),
+ () -> execute(test, group)));
+ }
+
+ final List groups = new ArrayList<>();
+ for (Map.Entry> group : byGroup.entrySet()) {
+ groups.add(dynamicContainer(group.getKey() + " (" + group.getValue().size() + ")", group.getValue()));
+ }
+
+ return dynamicContainer(file.getFileName().toString(), groups);
+ }
+
+ private static void execute(JSONObject test, String group) {
+ assumeTrue(TEST_GROUP_REQUIRED.equals(group), "recommended group: no lenient parsing mode");
+
+ AssertionError failure = null;
+ try {
+ run(test);
+ } catch (AssertionError e) {
+ failure = e;
+ } catch (RuntimeException e) {
+ failure = new AssertionError(e);
+ }
+
+ final String key = keyOf(test);
+ if (RECORD_GAPS) {
+ if (failure != null) {
+ RECORDED_GAPS.put(key, test);
+ }
+ } else if (KNOWN_GAP_KEYS.contains(key)) {
+ if (failure == null) {
+ fail("Known gap now passes; remove it from " + KNOWN_GAPS_RESOURCE + ": " + key);
+ }
+ } else if (failure != null) {
+ throw failure;
+ }
+ }
+
+ private static void run(JSONObject test) {
+ final String type = test.optString("test_type");
+ final boolean expectedFailure = test.optBoolean("expected_failure", false);
+
+ if ("build".equals(type)) {
+ executeBuild(test, expectedFailure);
+ } else if ("parse".equals(type) || "validate".equals(type)) {
+ executeParse(test, expectedFailure, "validate".equals(type));
+ } else {
+ throw new IllegalStateException("Unsupported test type: " + type);
+ }
+ }
+
+ private static void executeParse(JSONObject test, boolean expectedFailure, boolean roundTrip) {
+ final String input = test.getString("input");
+ if (expectedFailure) {
+ assertThrows(
+ MalformedPackageURLException.class, () -> new PackageURL(input), test.optString("description"));
+ return;
+ }
+
+ final PackageURL purl;
+ try {
+ purl = new PackageURL(input);
+ } catch (MalformedPackageURLException e) {
+ throw new AssertionError("Failed to parse " + input, e);
+ }
+
+ if (roundTrip) {
+ assertEquals(test.getString("expected_output"), purl.canonicalize(), "canonical form");
+ return;
+ }
+
+ final JSONObject expected = test.getJSONObject("expected_output");
+ assertEquals(optString(expected, "type"), purl.getType(), "type");
+ assertEquals(optString(expected, "namespace"), purl.getNamespace(), "namespace");
+ assertEquals(optString(expected, "name"), purl.getName(), "name");
+ assertEquals(optString(expected, "version"), purl.getVersion(), "version");
+ assertEquals(optString(expected, "subpath"), purl.getSubpath(), "subpath");
+ assertEquals(qualifiersOf(expected), purl.getQualifiers(), "qualifiers");
+ }
+
+ private static void executeBuild(JSONObject test, boolean expectedFailure) {
+ JSONObject input = test.getJSONObject("input");
+ if (expectedFailure) {
+ assertThrows(MalformedPackageURLException.class, () -> build(input), test.optString("description"));
+ return;
+ }
+
+ try {
+ assertEquals(test.getString("expected_output"), build(input).canonicalize(), "canonical form");
+ } catch (MalformedPackageURLException e) {
+ throw new AssertionError("Failed to build from " + input, e);
+ }
+ }
+
+ private static PackageURL build(JSONObject components) throws MalformedPackageURLException {
+ PackageURLBuilder builder = aPackageURL()
+ .withType(optString(components, "type"))
+ .withNamespace(optString(components, "namespace"))
+ .withName(optString(components, "name"))
+ .withVersion(optString(components, "version"))
+ .withSubpath(optString(components, "subpath"));
+
+ final JSONObject qualifiers = components.optJSONObject("qualifiers");
+ if (qualifiers != null) {
+ for (String key : sortedKeys(qualifiers)) {
+ builder.withQualifier(key, optString(qualifiers, key));
+ }
+ }
+
+ return builder.build();
+ }
+
+ private static Map qualifiersOf(JSONObject components) {
+ final JSONObject qualifiers = components.optJSONObject("qualifiers");
+ if (qualifiers == null) {
+ return Collections.emptyMap();
+ }
+
+ final Map result = new LinkedHashMap<>();
+ for (String key : sortedKeys(qualifiers)) {
+ String value = optString(qualifiers, key);
+ if (value != null && !value.isEmpty()) {
+ result.put(key, value);
+ }
+ }
+
+ return result;
+ }
+
+ private static String keyOf(JSONObject test) {
+ return test.optString("test_type") + " " + normalizedInput(test);
+ }
+
+ private static Object normalizedInput(JSONObject test) {
+ final Object input = test.get("input");
+ if (!(input instanceof JSONObject)) {
+ return input;
+ }
+
+ final JSONObject components = (JSONObject) input;
+ final JSONObject normalized = new JSONObject();
+ for (String field : PURL_COMPONENTS) {
+ normalized.put(field, normalizedComponent(components, field));
+ }
+
+ return normalized;
+ }
+
+ private static Object normalizedComponent(JSONObject components, String field) {
+ if (!"qualifiers".equals(field)) {
+ final String value = optString(components, field);
+ return value != null ? value : JSONObject.NULL;
+ }
+
+ final JSONObject qualifiers = components.optJSONObject(field);
+ if (qualifiers == null) {
+ return JSONObject.NULL;
+ }
+
+ final JSONObject sorted = new JSONObject();
+ for (String key : sortedKeys(qualifiers)) {
+ sorted.put(key, optString(qualifiers, key));
+ }
+
+ return sorted;
+ }
+
+ private static List sortedKeys(JSONObject object) {
+ final List keys = new ArrayList<>(object.keySet());
+ Collections.sort(keys);
+ return keys;
+ }
+
+ private static String optString(JSONObject object, String field) {
+ return object.isNull(field) ? null : object.optString(field, null);
+ }
+
+ private static Set loadKnownGaps() {
+ final Set keys = new HashSet<>();
+ try (InputStream in = PurlSpecConformanceTest.class.getResourceAsStream(KNOWN_GAPS_RESOURCE)) {
+ if (in == null) {
+ return keys;
+ }
+
+ final JSONArray gaps = new JSONObject(new JSONTokener(in)).optJSONArray("gaps");
+ for (int i = 0; gaps != null && i < gaps.length(); i++) {
+ keys.add(keyOf(gaps.getJSONObject(i)));
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+
+ return keys;
+ }
+
+ private static JSONObject readJson(Path file) {
+ try (InputStream in = Files.newInputStream(file)) {
+ return new JSONObject(new JSONTokener(in));
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to read " + file, e);
+ }
+ }
+}
diff --git a/src/test/resources/purl-conformance-known-gaps.json b/src/test/resources/purl-conformance-known-gaps.json
new file mode 100644
index 00000000..98020d73
--- /dev/null
+++ b/src/test/resources/purl-conformance-known-gaps.json
@@ -0,0 +1,301 @@
+{"gaps": [
+ {
+ "input": {
+ "namespace": "GDT",
+ "name": "URI::PackageURL",
+ "subpath": null,
+ "qualifiers": null,
+ "type": "cpan",
+ "version": null
+ },
+ "description": "CPAN distribution name like module name",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": "boost",
+ "name": "asio",
+ "subpath": null,
+ "qualifiers": null,
+ "type": "vcpkg",
+ "version": "1.84.0"
+ },
+ "description": "vcpkg build with a prohibited namespace",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": "codeberg.org",
+ "name": "forgejo/forgejo",
+ "subpath": "options/locale_readme.md",
+ "qualifiers": null,
+ "type": "git",
+ "version": "a72d2c07cfca03b55371089de6aa230d8c951fa0"
+ },
+ "description": "Build test for PURL type: git",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": "codeberg.org",
+ "name": "forgejo/forgejo",
+ "subpath": null,
+ "qualifiers": null,
+ "type": "git",
+ "version": "a72d2c07cfca03b55371089de6aa230d8c951fa0"
+ },
+ "description": "Build test for PURL type: git",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": "github.com/Alamofire",
+ "name": null,
+ "subpath": null,
+ "qualifiers": null,
+ "type": "swift",
+ "version": "5.4.3"
+ },
+ "description": "invalid swift purl without name",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": "namespace",
+ "name": "hex",
+ "subpath": null,
+ "qualifiers": null,
+ "type": "otp",
+ "version": "2.1.1"
+ },
+ "description": "Build test for PURL",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": "Alamofire",
+ "subpath": null,
+ "qualifiers": null,
+ "type": "swift",
+ "version": "5.4.3"
+ },
+ "description": "invalid swift purl without namespace",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": "EnterpriseLibrary.Common",
+ "subpath": null,
+ "qualifiers": null,
+ "type": null,
+ "version": "6.0.1304"
+ },
+ "description": "a type is always required",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": "URI::PackageURL",
+ "subpath": null,
+ "qualifiers": null,
+ "type": "cpan",
+ "version": null
+ },
+ "description": "CPAN distribution name like module name",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": "java",
+ "subpath": null,
+ "qualifiers": null,
+ "type": "vscode-extension",
+ "version": "1.46.2025091308"
+ },
+ "description": "Build test for VS Code Extension PURL without namespace should fail",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": "nginx",
+ "subpath": null,
+ "qualifiers": null,
+ "type": null,
+ "version": "0.8.9"
+ },
+ "description": "check for colon in type",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": null,
+ "subpath": null,
+ "qualifiers": null,
+ "type": "cran",
+ "version": "0.9.1"
+ },
+ "description": "invalid cran purl without name",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": null,
+ "subpath": null,
+ "qualifiers": null,
+ "type": "hackage",
+ "version": null
+ },
+ "description": "name and version are always required",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": null,
+ "subpath": null,
+ "qualifiers": null,
+ "type": "maven",
+ "version": null
+ },
+ "description": "a name is required",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": null,
+ "subpath": null,
+ "qualifiers": null,
+ "type": "opam",
+ "version": null
+ },
+ "description": "name is always required",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": null,
+ "subpath": null,
+ "qualifiers": null,
+ "type": "vcpkg",
+ "version": "1.0.8"
+ },
+ "description": "vcpkg build without a name",
+ "test_type": "build"
+ },
+ {
+ "input": {
+ "namespace": null,
+ "name": null,
+ "subpath": null,
+ "qualifiers": {"uuid": "ade2ca70-3891-5945-98fb-dc099432e06a"},
+ "type": "julia",
+ "version": "1.9.0"
+ },
+ "description": "invalid julia purl build test - null name",
+ "test_type": "build"
+ },
+ {
+ "input": "pkg:Rpm/fedora/curl@7.50.3-1.fc25?Arch=i386&Distro=fedora-25",
+ "description": "rpm often use qualifiers",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:brew/Homebrew/Core/sqlite@3.43.2",
+ "description": "Homebrew tap namespaces are not case sensitive",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:brew/SQLite@3.43.2",
+ "description": "Homebrew formula names are not case sensitive",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:chrome-extension/44444algnefjeiefhmpklpfiohadpglk",
+ "description": "invalid chrome purl (name: invalid characters)",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:chrome-extension/dlpngalgnefjeiefhmpklpfiohadpglk@1.2.3-beta",
+ "description": "invalid chrome purl (version: invalid characters)",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:chrome-extension/dlpngalgnefjeiefhmpklpfiohadpglk@1.2.3.4.5",
+ "description": "invalid chrome purl (version: too many segments)",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:chrome-extension/dogs",
+ "description": "invalid chrome purl (name: wrong length)",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:cpan/GDT/URI::PackageURL",
+ "description": "CPAN distribution name as module name",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:cpan/LWP::UserAgent@6.7.6",
+ "description": "CPAN with just the module name and version",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:cpan/URI::PackageURL",
+ "description": "CPAN distribution name as module name",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:gem/jruby-launcher@1.1.2?Platform=java",
+ "description": "Ruby gems can use qualifiers",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:git/codeberg.org/forgejo/forgejo@a72d2c07cfca03b55371089de6aa230d8c951fa0",
+ "description": "Parse test for PURL type: git",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:git/codeberg.org/forgejo/forgejo@a72d2c07cfca03b55371089de6aa230d8c951fa0#options/locale_readme.md",
+ "description": "Parse test for PURL type: git",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:julia/Dates",
+ "description": "invalid julia purl for package without uuid",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:mlflow/CreditFraud@3?repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow",
+ "description": "MLflow model tracked in Azure Databricks (case insensitive)",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:otp/namespace/hex@2.1.1",
+ "description": "Parse test for PURL",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:swift/Alamofire@5.4.3",
+ "description": "invalid swift purl without namespace",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:vcpkg/boost/asio@1.84.0",
+ "description": "vcpkg namespace is prohibited; boost-asio is a single port name, not a namespace and name",
+ "test_type": "parse"
+ },
+ {
+ "input": "pkg:vscode-extension/java@1.46.2025091308",
+ "description": "Parse test for VS Code Extension PURL without namespace should fail",
+ "test_type": "parse"
+ }
+]}
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",