Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion component_catalog/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 = {
Expand Down
1 change: 0 additions & 1 deletion component_catalog/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions dje/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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))

Expand Down
26 changes: 23 additions & 3 deletions dje/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down