Skip to content

Commit 9d5461f

Browse files
committed
Add integrity API for serving PEP 740 Provenance objects
1 parent bc59a76 commit 9d5461f

File tree

5 files changed

+127
-25
lines changed

5 files changed

+127
-25
lines changed

pulp_python/app/pypi/views.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
PythonDistribution,
3838
PythonPackageContent,
3939
PythonPublication,
40+
PackageProvenance,
4041
)
4142
from pulp_python.app.pypi.serializers import (
4243
SummarySerializer,
@@ -61,6 +62,7 @@
6162

6263
ORIGIN_HOST = settings.CONTENT_ORIGIN if settings.CONTENT_ORIGIN else settings.PYPI_API_HOSTNAME
6364
BASE_CONTENT_URL = urljoin(ORIGIN_HOST, settings.CONTENT_PATH_PREFIX)
65+
BASE_API_URL = urljoin(settings.PYPI_API_HOSTNAME, "pypi/")
6466

6567
PYPI_SIMPLE_V1_HTML = "application/vnd.pypi.simple.v1+html"
6668
PYPI_SIMPLE_V1_JSON = "application/vnd.pypi.simple.v1+json"
@@ -120,6 +122,11 @@ def get_content(repository_version):
120122
"""Returns queryset of the content in this repository version."""
121123
return PythonPackageContent.objects.filter(pk__in=repository_version.content)
122124

125+
@staticmethod
126+
def get_provenances(repository_version):
127+
"""Returns queryset of the provenance for this repository version."""
128+
return PackageProvenance.objects.filter(pk__in=repository_version.content)
129+
123130
def should_redirect(self, repo_version=None):
124131
"""Checks if there is a publication the content app can serve."""
125132
if self.distribution.publication:
@@ -139,10 +146,13 @@ def get_rvc(self):
139146
def initial(self, request, *args, **kwargs):
140147
"""Perform common initialization tasks for PyPI endpoints."""
141148
super().initial(request, *args, **kwargs)
149+
domain_name = get_domain().name
142150
if settings.DOMAIN_ENABLED:
143-
self.base_content_url = urljoin(BASE_CONTENT_URL, f"{get_domain().name}/")
151+
self.base_content_url = urljoin(BASE_CONTENT_URL, f"{domain_name}/")
152+
self.base_api_url = urljoin(BASE_API_URL, f"{domain_name}/")
144153
else:
145154
self.base_content_url = BASE_CONTENT_URL
155+
self.base_api_url = BASE_API_URL
146156

147157
@classmethod
148158
def urlpattern(cls):
@@ -273,6 +283,13 @@ def get_renderers(self):
273283
else:
274284
return [JSONRenderer(), BrowsableAPIRenderer()]
275285

286+
def get_provenance_url(self, package, version, filename):
287+
"""Gets the provenance url for a package."""
288+
base_path = self.distribution.base_path
289+
return urljoin(
290+
self.base_api_url, f"{base_path}/integrity/{package}/{version}/{filename}/provenance/"
291+
)
292+
276293
@extend_schema(summary="Get index simple page")
277294
def list(self, request, path):
278295
"""Gets the simple api html page for the index."""
@@ -308,6 +325,7 @@ def parse_package(release_package):
308325
"size": release_package.size,
309326
"upload_time": release_package.upload_time,
310327
"version": release_package.version,
328+
"provenance": release_package.provenance_url,
311329
}
312330

313331
rfilter = get_remote_package_filter(remote)
@@ -348,7 +366,8 @@ def retrieve(self, request, path, package):
348366
elif self.should_redirect(repo_version=repo_ver):
349367
return redirect(urljoin(self.base_content_url, f"{path}/simple/{normalized}/"))
350368
if content:
351-
packages = content.filter(name__normalize=normalized).values(
369+
local_packages = content.filter(name__normalize=normalized)
370+
packages = local_packages.values(
352371
"filename",
353372
"sha256",
354373
"metadata_sha256",
@@ -357,11 +376,19 @@ def retrieve(self, request, path, package):
357376
"pulp_created",
358377
"version",
359378
)
379+
provenances = PackageProvenance.objects.filter(package__in=local_packages).values_list(
380+
"package__filename", flat=True
381+
)
360382
local_releases = {
361383
p["filename"]: {
362384
**p,
363385
"url": urljoin(self.base_content_url, f"{path}/{p['filename']}"),
364386
"upload_time": p["pulp_created"],
387+
"provenance": (
388+
self.get_provenance_url(normalized, p["version"], p["filename"])
389+
if p["filename"] in provenances
390+
else None
391+
),
365392
}
366393
for p in packages
367394
}
@@ -497,3 +524,32 @@ def create(self, request, path):
497524
This is the endpoint that tools like Twine and Poetry use for their upload commands.
498525
"""
499526
return self.upload(request, path)
527+
528+
529+
class ProvenanceView(PyPIMixin, ViewSet):
530+
"""View for the PyPI provenance endpoint."""
531+
532+
endpoint_name = "integrity"
533+
DEFAULT_ACCESS_POLICY = {
534+
"statements": [
535+
{
536+
"action": ["retrieve"],
537+
"principal": "*",
538+
"effect": "allow",
539+
},
540+
],
541+
}
542+
543+
@extend_schema(summary="Get package provenance")
544+
def retrieve(self, request, path, package, version, filename):
545+
"""Gets the provenance for a package."""
546+
repo_ver, content = self.get_rvc()
547+
if content:
548+
package_content = content.filter(
549+
name__normalize=package, version=version, filename=filename
550+
).first()
551+
if package_content:
552+
provenance = PackageProvenance.objects.filter(package=package_content).first()
553+
if provenance:
554+
return Response(data=provenance.provenance)
555+
return HttpResponseNotFound(f"{package} {version} {filename} provenance does not exist.")

pulp_python/app/urls.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
from django.conf import settings
22
from django.urls import path
33

4-
from pulp_python.app.pypi.views import SimpleView, MetadataView, PyPIView, UploadView
4+
from pulp_python.app.pypi.views import (
5+
SimpleView,
6+
MetadataView,
7+
PyPIView,
8+
UploadView,
9+
ProvenanceView,
10+
)
511

612
if settings.DOMAIN_ENABLED:
713
PYPI_API_URL = "pypi/<slug:pulp_domain>/<path:path>/"
@@ -13,6 +19,11 @@
1319

1420
urlpatterns = [
1521
path(PYPI_API_URL + "legacy/", UploadView.as_view({"post": "create"}), name="upload"),
22+
path(
23+
PYPI_API_URL + "integrity/<str:package>/<str:version>/<str:filename>/provenance/",
24+
ProvenanceView.as_view({"get": "retrieve"}),
25+
name="integrity-provenance",
26+
),
1627
path(
1728
PYPI_API_URL + "pypi/<path:meta>/",
1829
MetadataView.as_view({"get": "retrieve"}),

pulp_python/app/utils.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"""TODO This serial constant is temporary until Python repositories implements serials"""
2020
PYPI_SERIAL_CONSTANT = 1000000000
2121

22-
SIMPLE_API_VERSION = "1.1"
22+
SIMPLE_API_VERSION = "1.3"
2323

2424
simple_index_template = """<!DOCTYPE html>
2525
<html>
@@ -44,7 +44,8 @@
4444
<body>
4545
<h1>Links for {{ project_name }}</h1>
4646
{% for pkg in project_packages %}
47-
<a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal">{{ pkg.filename }}</a><br/>
47+
<a href="{{ pkg.url }}#sha256={{ pkg.sha256 }}" rel="internal" {% if pkg.provenance -%}
48+
data-provenance="{{ pkg.provenance }}"{% endif %}>{{ pkg.filename }}</a><br/>
4849
{% endfor %}
4950
</body>
5051
</html>
@@ -478,7 +479,8 @@ def write_simple_detail_json(project_name, project_packages):
478479
"upload-time": format_upload_time(package["upload_time"]),
479480
# TODO in the future:
480481
# core-metadata (PEP 7.14)
481-
# provenance (v1.3, PEP 740)
482+
# (v1.3, PEP 740)
483+
"provenance": package.get("provenance", None),
482484
}
483485
for package in project_packages
484486
],

pulp_python/tests/functional/api/test_attestations.py

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,47 @@
66
from pulpcore.tests.functional.utils import PulpTaskError
77

88

9-
@pytest.mark.parallel
10-
def test_crd_provenance(python_bindings, python_content_factory, monitor_task):
11-
"""
12-
Test creating and reading a provenance.
13-
"""
9+
@pytest.fixture(scope="session")
10+
def twine_package():
11+
"""Returns the twine package."""
1412
filename = "twine-6.2.0-py3-none-any.whl"
1513
with PyPISimple() as client:
1614
page = client.get_project_page("twine")
1715
for package in page.packages:
1816
if package.filename == filename:
19-
content = python_content_factory(filename, url=package.url)
20-
break
17+
return package
18+
19+
raise ValueError("Twine package not found")
20+
21+
22+
@pytest.mark.parallel
23+
def test_crd_provenance(python_bindings, twine_package, python_content_factory, monitor_task):
24+
"""
25+
Test creating and reading a provenance.
26+
"""
27+
content = python_content_factory(filename=twine_package.filename, url=twine_package.url)
28+
2129
provenance = python_bindings.ContentProvenanceApi.create(
2230
package=content.pulp_href,
23-
file_url=package.provenance_url,
31+
file_url=twine_package.provenance_url,
2432
)
2533
task = monitor_task(provenance.task)
2634
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[0])
2735
assert provenance.package == content.pulp_href
28-
r = requests.get(package.provenance_url)
36+
r = requests.get(twine_package.provenance_url)
2937
assert r.status_code == 200
3038
assert r.json() == provenance.provenance
3139

3240

3341
@pytest.mark.parallel
34-
def test_verify_provenance(python_bindings, python_content_factory, monitor_task):
42+
def test_verify_provenance(python_bindings, twine_package, python_content_factory, monitor_task):
3543
"""
3644
Test verifying a provenance.
3745
"""
38-
filename = "twine-6.2.0.tar.gz"
39-
with PyPISimple() as client:
40-
page = client.get_project_page("twine")
41-
for package in page.packages:
42-
if package.filename == filename:
43-
break
4446
wrong_content = python_content_factory() # shelf-reader-0.1.tar.gz
4547
provenance = python_bindings.ContentProvenanceApi.create(
4648
package=wrong_content.pulp_href,
47-
file_url=package.provenance_url,
49+
file_url=twine_package.provenance_url,
4850
)
4951
with pytest.raises(PulpTaskError) as e:
5052
monitor_task(provenance.task)
@@ -54,9 +56,39 @@ def test_verify_provenance(python_bindings, python_content_factory, monitor_task
5456
# Test creating a provenance without verifying
5557
provenance = python_bindings.ContentProvenanceApi.create(
5658
package=wrong_content.pulp_href,
57-
file_url=package.provenance_url,
59+
file_url=twine_package.provenance_url,
5860
verify=False,
5961
)
6062
task = monitor_task(provenance.task)
6163
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[0])
6264
assert provenance.package == wrong_content.pulp_href
65+
66+
67+
@pytest.mark.parallel
68+
def test_integrity_api(
69+
python_bindings,
70+
python_repo,
71+
python_distribution_factory,
72+
twine_package,
73+
python_content_factory,
74+
monitor_task,
75+
):
76+
"""
77+
Test the integrity API.
78+
"""
79+
content = python_content_factory(
80+
filename=twine_package.filename, repository=python_repo.pulp_href, url=twine_package.url
81+
)
82+
provenance = python_bindings.ContentProvenanceApi.create(
83+
package=content.pulp_href,
84+
file_url=twine_package.provenance_url,
85+
repository=python_repo.pulp_href,
86+
)
87+
task = monitor_task(provenance.task)
88+
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[0])
89+
90+
distro = python_distribution_factory(repository=python_repo.pulp_href)
91+
url = f"{distro.base_url}integrity/{twine_package.name}/{twine_package.version}/{twine_package.filename}/provenance/" # noqa: E501
92+
r = requests.get(url)
93+
assert r.status_code == 200
94+
assert r.json() == provenance.provenance

pulp_python/tests/functional/api/test_pypi_simple_json_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
PYTHON_WHEEL_URL,
1212
)
1313

14-
API_VERSION = "1.1"
14+
API_VERSION = "1.3"
1515
PYPI_SERIAL_CONSTANT = 1000000000
1616

1717
PYPI_TEXT_HTML = "text/html"
@@ -97,6 +97,7 @@ def test_simple_json_detail_api(
9797
assert file_tar["data-dist-info-metadata"] is False
9898
assert file_tar["size"] == 19097
9999
assert file_tar["upload-time"] is not None
100+
assert file_tar["provenance"] is None
100101

101102

102103
@pytest.mark.parallel

0 commit comments

Comments
 (0)