diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index b52e0006..c438f451 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/.gitmodules b/.gitmodules new file mode 100644 index 00000000..309dd059 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "purl-spec"] + path = purl-spec + url = https://github.com/package-url/purl-spec.git diff --git a/pom.xml b/pom.xml index 8923361a..982f05b5 100644 --- a/pom.xml +++ b/pom.xml @@ -215,6 +215,11 @@ bnd-maven-plugin ${bnd.maven.plugin.version} + + org.codehaus.mojo + exec-maven-plugin + ${exec.maven.plugin.version} + org.apache.maven.plugins maven-clean-plugin @@ -597,6 +602,28 @@ + + org.codehaus.mojo + exec-maven-plugin + + + git-submodule-update + + exec + + initialize + + git + + submodule + update + --init + --recursive + + + + + org.apache.maven.plugins maven-enforcer-plugin @@ -663,7 +690,6 @@ org.codehaus.mojo exec-maven-plugin - ${exec.maven.plugin.version} 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",