diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index b52e0006..3242e9c2 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -17,6 +17,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4.2.2
+ with:
+ submodules: true
- name: Set up JDK 8 and ${{ matrix.java-version }}
uses: actions/setup-java@v4
diff --git a/.gitignore b/.gitignore
index 17635482..04542fb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,7 @@ buildNumber.properties
.idea
*.iml
##########################################################################
+
+#### ignore VSCODE config
+.vscode/
+##########################################################################
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..947dcc8c
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "src/test/resources/purl-spec"]
+ path = src/test/resources/purl-spec
+ url = https://github.com/package-url/purl-spec
diff --git a/pom.xml b/pom.xml
index 8923361a..b2c6ce19 100644
--- a/pom.xml
+++ b/pom.xml
@@ -141,6 +141,7 @@
1.37
20250107
5.13.3
+ 2.19.2
1.4.0
@@ -200,6 +201,11 @@
junit-jupiter-params
test
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${lib.jackson-databind.version}
+
diff --git a/src/test/java/com/github/packageurl/PurlSpecRefTest.java b/src/test/java/com/github/packageurl/PurlSpecRefTest.java
new file mode 100644
index 00000000..e27bcb95
--- /dev/null
+++ b/src/test/java/com/github/packageurl/PurlSpecRefTest.java
@@ -0,0 +1,216 @@
+/*
+ * 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.
+ *
+ * SPDX-License-Identifier: MIT
+ * Copyright (c) AboutCode, and contributors. All Rights Reserved.
+ */
+
+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.nio.file.Files;
+import java.nio.file.Paths;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+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.databind.DeserializationContext;
+
+import java.util.Map;
+
+public class PurlSpecRefTest {
+
+ public static class TestSuite {
+ @JsonProperty("$schema")
+ public String schema;
+ public List tests;
+ }
+
+ public static class TestCase {
+ public String description;
+ public String test_group;
+ public String test_type;
+
+ public PurlOrComponent input;
+ public PurlOrComponent expected_output;
+
+ public boolean expected_failure;
+ public String expected_failure_reason;
+
+ public TestCase() {
+ }
+ }
+
+ @JsonDeserialize(using = PurlOrComponentDeserializer.class)
+ public static class PurlOrComponent {
+ public String purl;
+ public PurlComponents components;
+ }
+
+ public static class PurlComponents {
+ public String type;
+ public String namespace;
+ public String name;
+ public String version;
+ public Map qualifiers;
+ public String subpath;
+ }
+
+ public static class PurlOrComponentDeserializer extends JsonDeserializer {
+ @Override
+ public PurlOrComponent deserialize(JsonParser p, DeserializationContext ctxt)
+ throws IOException, JsonProcessingException {
+ ObjectCodec codec = p.getCodec();
+ JsonNode node = codec.readTree(p);
+
+ PurlOrComponent value = new PurlOrComponent();
+
+ if (node.isTextual()) {
+ value.purl = node.asText();
+ } else if (node.isObject()) {
+ value.components = codec.treeToValue(node, PurlComponents.class);
+ }
+
+ return value;
+ }
+ }
+
+ static Stream collectTestCases() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+
+ URL dirURL = PurlSpecRefTest.class.getClassLoader().getResource("purl-spec/tests/types/");
+ if (dirURL == null) {
+ throw new RuntimeException("Resource directory 'purl-spec/tests/types/' not found.");
+ }
+
+ Path testDataPath = Paths.get(dirURL.toURI());
+ List jsonFiles = Files.list(testDataPath)
+ .filter(p -> p.toString().endsWith(".json"))
+ .collect(Collectors.toList());
+
+ Stream.Builder builder = Stream.builder();
+
+ for (Path jsonFile : jsonFiles) {
+ try (InputStream is = Files.newInputStream(jsonFile)) {
+ TestSuite suite = mapper.readValue(is, TestSuite.class);
+ suite.tests.forEach(builder::add);
+ }
+ }
+
+ return builder.build();
+ }
+
+ void runRoundtripTest(TestCase testCase) throws Exception {
+ String result;
+ try {
+ result = new PackageURL(testCase.input.purl).canonicalize().toString();
+ } 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);
+
+ }
+
+ void runBuildTest(TestCase testCase) throws Exception {
+ PurlComponents input = testCase.input.components;
+ String result;
+ try {
+ result = new PackageURL(input.type, input.namespace, input.name, input.version, input.qualifiers,
+ input.subpath).canonicalize().toString();
+ } 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);
+ }
+
+ void runParseTest(TestCase testCase) throws Exception {
+ PackageURL result;
+ try {
+ result = new PackageURL(testCase.input.purl);
+ } catch (Exception e) {
+ assertTrue(testCase.expected_failure, "Unexpected failure: " + e.getMessage());
+ return;
+ }
+ assertFalse(testCase.expected_failure, "Expected failure but parsing succeeded");
+
+ PurlComponents expected = testCase.expected_output.components;
+ result.canonicalize();
+
+ assertEquals(expected.type, result.getType(), "Type mismatch");
+ assertEquals(expected.namespace, result.getNamespace(), "Namespace mismatch");
+ assertEquals(expected.name, result.getName(), "Name mismatch");
+ assertEquals(expected.version, result.getVersion(), "Version mismatch");
+ assertEquals(expected.subpath, result.getSubpath(), "Subpath mismatch");
+
+ assertEquals(
+ expected.qualifiers != null ? expected.qualifiers : Collections.emptyMap(),
+ result.getQualifiers(),
+ "Qualifiers mismatch");
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("collectTestCases")
+ void runTest(TestCase testCase) throws Exception {
+ switch (testCase.test_type) {
+ case "roundtrip":
+ runRoundtripTest(testCase);
+ break;
+ case "build":
+ runBuildTest(testCase);
+ break;
+ case "parse":
+ runParseTest(testCase);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown test_type: " + testCase.test_type);
+ }
+
+ }
+
+}
diff --git a/src/test/resources/purl-spec b/src/test/resources/purl-spec
new file mode 160000
index 00000000..414fef48
--- /dev/null
+++ b/src/test/resources/purl-spec
@@ -0,0 +1 @@
+Subproject commit 414fef487025046691af67f70dfa8677139df92d