From fc37c70062b4ba1a1616aa7e9a35526a68f4190d Mon Sep 17 00:00:00 2001 From: Ajay Rajera Date: Sun, 12 Jul 2026 11:00:35 +0530 Subject: [PATCH] Fix Go package PURL mapping by normalizing percent-encoding in plain PURL comparison Signed-off-by: Ajay Rajera --- component_catalog/tests/test_models.py | 38 +++++++++++++++++++++++++- component_catalog/views.py | 1 - dje/tests/test_utils.py | 18 ++++++++++++ dje/utils.py | 26 ++++++++++++++++-- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/component_catalog/tests/test_models.py b/component_catalog/tests/test_models.py index ecb9fdc5..971dc9d1 100644 --- a/component_catalog/tests/test_models.py +++ b/component_catalog/tests/test_models.py @@ -2628,7 +2628,7 @@ def test_package_model_get_purldb_entries(self, mock_find_packages): mock_find_packages.return_value = [purldb_entry1, purldb_entry2, purldb_entry3] purldb_entries = package1.get_purldb_entries(user=self.user) - # The purldb_entry2 is excluded as the PURL differs + # The purldb_entry3 is excluded as its plain PURL differs (no version) self.assertEqual([purldb_entry1, purldb_entry2], purldb_entries) @mock.patch("dejacode_toolkit.purldb.PurlDB.find_packages") @@ -2645,6 +2645,42 @@ def test_package_model_get_purldb_entries_plain_purls_equal(self, mock_find_pack purldb_entries = package1.get_purldb_entries(user=self.user) self.assertEqual([purldb_entry1, purldb_entry2], purldb_entries) + @mock.patch("dejacode_toolkit.purldb.PurlDB.find_packages") + def test_package_model_get_purldb_entries_fallback_to_purl(self, mock_find_packages): + """ + Test that get_purldb_entries falls through to PURL lookup when an earlier + strategy (e.g. download_url) returns no results. + + This covers the Go package mapping bug (issue #462): packages imported from an + SBOM may have an inferred download_url that PurlDB does not recognise, while + their PURL *is* indexed in PurlDB. Without the fallback the PurlDB tab was + showing "No entries found" even though "Improve Packages from PurlDB" worked + (because that code path does not limit the number of requests). + """ + go_purl = "pkg:golang/github.com/gin-gonic/gin@v1.9.0" + inferred_download_url = ( + "https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.0.zip" + ) + package1 = make_package( + self.dataspace, + package_url=go_purl, + download_url=inferred_download_url, + ) + purldb_entry = { + "purl": go_purl, + "type": "golang", + "namespace": "github.com/gin-gonic", + "name": "gin", + "version": "v1.9.0", + } + + # Simulate: download_url lookup fails (None), PURL lookup succeeds. + mock_find_packages.side_effect = [None, [purldb_entry]] + purldb_entries = package1.get_purldb_entries(user=self.user) + self.assertEqual([purldb_entry], purldb_entries) + # Ensure both payloads were tried (download_url first, then purl). + self.assertEqual(2, mock_find_packages.call_count) + @mock.patch("component_catalog.models.Package.get_purldb_entries") def test_package_model_update_from_purldb(self, mock_get_purldb_entries): purldb_entry = { diff --git a/component_catalog/views.py b/component_catalog/views.py index a650e000..46c8b8bd 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -2546,7 +2546,6 @@ def get_tab_fields(self): purldb_entries = self.object.get_purldb_entries( user=self.request.user, - max_request_call=1, timeout=5, ) if not purldb_entries: diff --git a/dje/tests/test_utils.py b/dje/tests/test_utils.py index 317c4208..89f720d1 100644 --- a/dje/tests/test_utils.py +++ b/dje/tests/test_utils.py @@ -531,12 +531,19 @@ def test_utils_is_purl_fragment(self): def test_utils_get_plain_purl(self): self.assertEqual("", get_plain_purl(None)) self.assertEqual("", get_plain_purl("")) + # Invalid PURLs fall back to simple string split (no exception raised) self.assertEqual("not:a/purl", get_plain_purl("not:a/purl")) self.assertEqual("not:a/purl", get_plain_purl("not:a/purl")) self.assertEqual("pkg:npm/is-npm@1.0.0", get_plain_purl("pkg:npm/is-npm@1.0.0")) self.assertEqual( "pkg:npm/is-npm@1.0.0", get_plain_purl("pkg:npm/is-npm@1.0.0?qualifier=1#frament") ) + # Percent-encoded and literal forms of the same version compare equal. + # "+" in a version is encoded as "%2B" by PackageURL.to_string(). + self.assertEqual( + get_plain_purl("pkg:golang/github.com/foo/bar@v1.0.0%2Bincompatible"), + get_plain_purl("pkg:golang/github.com/foo/bar@v1.0.0+incompatible"), + ) def test_utils_plain_purls_equal(self): purl1 = "pkg:npm/is-npm@1.0.0" @@ -555,6 +562,17 @@ def test_utils_plain_purls_equal(self): purl2 = "pkg:npm/is-npm@2.0.0" self.assertFalse(plain_purls_equal(purl1, purl2)) + # Go packages: percent-encoded "+" vs literal "+" in +incompatible versions + # must be treated as equal (issue #462). + purl1 = "pkg:golang/github.com/docker/docker@v19.03.15%2Bincompatible" + purl2 = "pkg:golang/github.com/docker/docker@v19.03.15+incompatible" + self.assertTrue(plain_purls_equal(purl1, purl2)) + + # Different versions must still be not-equal. + purl1 = "pkg:golang/github.com/docker/docker@v19.03.15+incompatible" + purl2 = "pkg:golang/github.com/docker/docker@v20.10.0+incompatible" + self.assertFalse(plain_purls_equal(purl1, purl2)) + def test_utils_localized_datetime(self): self.assertIsNone(localized_datetime(None)) diff --git a/dje/utils.py b/dje/utils.py index 0d1d734b..46c330c2 100644 --- a/dje/utils.py +++ b/dje/utils.py @@ -663,17 +663,37 @@ def is_purl_fragment(string): def get_plain_purl(purl_str): - """Remove the qualifiers and subpath from the `purl_str```.""" + """ + Remove the qualifiers and subpath from the ``purl_str``. + + The comparison is normalised through ``PackageURL.from_string`` so that + percent-encoded variants of the same PURL are treated as equal + (e.g. ``@v1.0.0%2Bincompatible`` and ``@v1.0.0+incompatible``). + + Falls back to a simple ``split("?")`` strip when the string is not a + valid PURL (so callers never get an unexpected exception). + """ if not purl_str: return "" - return purl_str.split("?")[0] + try: + purl = PackageURL.from_string(str(purl_str)) + # Re-serialise without qualifiers or subpath for a canonical plain PURL. + return purl.to_string().split("?")[0].split("#")[0] + except ValueError: + return str(purl_str).split("?")[0] def plain_purls_equal(purl1, purl2): - """Check if two PURLs are equal, ignoring qualifiers and subpath.""" + """Check if two PURLs are equal, ignoring qualifiers and subpath. + + Percent-encoding differences (e.g. ``+`` vs ``%2B``) are normalised + before the comparison so semantically identical PURLs are always + considered equal. + """ return get_plain_purl(purl1) == get_plain_purl(purl2) + def remove_empty_values(input_dict): """ Return a new dict not including empty value entries from `input_dict`.