Skip to content

Commit e94ed1c

Browse files
committed
fix: issue 795-download_url from PURL when not provided
Signed-off-by: Ruchit Agrawal <rragrawal16@gmail.com>
1 parent 469c506 commit e94ed1c

3 files changed

Lines changed: 171 additions & 0 deletions

File tree

packagedb/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from rest_framework.authtoken.models import Token
3939

4040
from packagedb import schedules
41+
from packagedb.purl_url_utils import derive_download_url
4142

4243
TRACE = False
4344

@@ -574,6 +575,19 @@ class Meta:
574575
def __str__(self):
575576
return self.package_url
576577

578+
def save(self, *args, **kwargs):
579+
"""
580+
Override save to auto-derive download_url from PURL if not provided.
581+
582+
Packages coming from federatedcode repos may not have a download_url.
583+
We use purl2url to infer a real download URL when possible, and fall
584+
back to a synthetic unique URL derived from the PURL components.
585+
"""
586+
if not self.download_url and self.purl:
587+
self.download_url = derive_download_url(self.purl)
588+
589+
super().save(*args, **kwargs)
590+
577591
@property
578592
def purl(self):
579593
return self.package_url

packagedb/purl_url_utils.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# purldb is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/purldb for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import logging
11+
12+
from packageurl import PackageURL
13+
from packageurl.contrib import purl2url
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def derive_download_url(purl_string, provided_download_url=None):
19+
"""
20+
Return a download URL for the package identified by ``purl_string``.
21+
22+
If ``provided_download_url`` is given it is returned as-is. Otherwise
23+
purl2url is used to infer a real download URL. When that also fails a
24+
synthetic URL is built from the PURL components so that the unique
25+
constraint on ``Package.download_url`` can still be satisfied.
26+
"""
27+
if provided_download_url:
28+
return provided_download_url
29+
30+
try:
31+
download_url = purl2url.get_download_url(purl_string)
32+
if download_url:
33+
return download_url
34+
except Exception:
35+
pass
36+
37+
# Fall back to a synthetic URL so the uniqueness constraint is satisfied
38+
# even when no real download URL is available (e.g. packages from
39+
# federatedcode that only carry a PURL).
40+
try:
41+
purl = PackageURL.from_string(purl_string)
42+
return generate_synthetic_download_url(purl)
43+
except Exception as e:
44+
logger.warning(f"Could not generate download URL for {purl_string!r}: {e}")
45+
return f"purl:{purl_string}"
46+
47+
48+
def generate_synthetic_download_url(purl):
49+
"""
50+
Return a synthetic download URL for ``purl`` in the form:
51+
purl://<type>/<namespace>/<name>@<version>?<qualifiers>#<subpath>
52+
53+
All PURL components that affect identity are included so that two
54+
packages which differ only by qualifier (e.g. Maven JARs with different
55+
classifiers) still receive distinct synthetic URLs.
56+
"""
57+
parts = ["purl://", purl.type]
58+
59+
if purl.namespace:
60+
parts += ["/", purl.namespace]
61+
62+
parts += ["/", purl.name]
63+
64+
if purl.version:
65+
parts += ["@", purl.version]
66+
67+
if purl.qualifiers:
68+
qual_str = "&".join(f"{k}={v}" for k, v in sorted(purl.qualifiers.items()))
69+
parts += ["?", qual_str]
70+
71+
if purl.subpath:
72+
parts += ["#", purl.subpath]
73+
74+
return "".join(parts)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# purldb is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/purldb for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import unittest
11+
from unittest.mock import patch
12+
13+
from packageurl import PackageURL
14+
15+
from packagedb.purl_url_utils import derive_download_url
16+
from packagedb.purl_url_utils import generate_synthetic_download_url
17+
18+
19+
class TestDeriveDownloadURL(unittest.TestCase):
20+
def test_provided_url_takes_precedence(self):
21+
provided = "https://example.com/lodash-4.17.21.tgz"
22+
result = derive_download_url("pkg:npm/lodash@4.17.21", provided)
23+
self.assertEqual(result, provided)
24+
25+
@patch("packagedb.purl_url_utils.purl2url.get_download_url")
26+
def test_infers_url_from_purl(self, mock_get_download):
27+
expected = "https://rubygems.org/downloads/bundler-2.3.23.gem"
28+
mock_get_download.return_value = expected
29+
30+
result = derive_download_url("pkg:gem/bundler@2.3.23")
31+
32+
mock_get_download.assert_called_once_with("pkg:gem/bundler@2.3.23")
33+
self.assertEqual(result, expected)
34+
35+
@patch("packagedb.purl_url_utils.purl2url.get_download_url")
36+
def test_falls_back_to_synthetic_url(self, mock_get_download):
37+
mock_get_download.side_effect = Exception("cannot infer")
38+
39+
result = derive_download_url("pkg:generic/some-package@1.0.0")
40+
41+
self.assertTrue(result.startswith("purl://"))
42+
self.assertIn("generic/some-package@1.0.0", result)
43+
44+
def test_invalid_purl_does_not_raise(self):
45+
# Last-resort fallback: returns a purl:-prefixed string
46+
result = derive_download_url("not-a-valid-purl")
47+
self.assertIsNotNone(result)
48+
self.assertIn("purl:", result)
49+
50+
51+
class TestGenerateSyntheticDownloadURL(unittest.TestCase):
52+
def test_basic(self):
53+
purl = PackageURL.from_string("pkg:npm/express@4.17.1")
54+
self.assertEqual(generate_synthetic_download_url(purl), "purl://npm/express@4.17.1")
55+
56+
def test_includes_namespace(self):
57+
purl = PackageURL.from_string("pkg:maven/org.apache.commons/commons-lang3@3.12.0")
58+
url = generate_synthetic_download_url(purl)
59+
self.assertTrue(url.startswith("purl://maven/org.apache.commons/"))
60+
self.assertIn("commons-lang3@3.12.0", url)
61+
62+
def test_qualifiers_differentiate_packages(self):
63+
# Maven JARs with different classifiers must produce different URLs
64+
purl1 = PackageURL.from_string("pkg:maven/com.example/lib@1.0.0")
65+
purl2 = PackageURL.from_string("pkg:maven/com.example/lib@1.0.0?classifier=sources")
66+
self.assertNotEqual(
67+
generate_synthetic_download_url(purl1),
68+
generate_synthetic_download_url(purl2),
69+
)
70+
71+
def test_no_version(self):
72+
purl = PackageURL.from_string("pkg:npm/express")
73+
url = generate_synthetic_download_url(purl)
74+
self.assertNotIn("@", url)
75+
76+
def test_includes_subpath(self):
77+
purl = PackageURL.from_string("pkg:github/user/repo@v1.0#path/to/file")
78+
url = generate_synthetic_download_url(purl)
79+
self.assertIn("#path/to/file", url)
80+
81+
82+
if __name__ == "__main__":
83+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)