Skip to content

Commit 96b89db

Browse files
authored
Merge pull request #1032 from gerrod3/integrity
Add integrity API
2 parents bc59a76 + 7c5148b commit 96b89db

File tree

5 files changed

+137
-28
lines changed

5 files changed

+137
-28
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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,57 +6,96 @@
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-
"""
14-
filename = "twine-6.2.0-py3-none-any.whl"
9+
@pytest.fixture(scope="session")
10+
def twine_package():
11+
"""Returns the twine package."""
12+
filename = "twine-6.2.0.tar.gz"
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(relative_path=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)
26-
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[0])
34+
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[-1])
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
44-
wrong_content = python_content_factory() # shelf-reader-0.1.tar.gz
46+
wrong_content = python_content_factory(
47+
relative_path=twine_package.filename, url=twine_package.url
48+
)
49+
prov_url = twine_package.provenance_url.replace(
50+
"twine-6.2.0.tar.gz", "twine-6.2.0-py3-none-any.whl"
51+
)
4552
provenance = python_bindings.ContentProvenanceApi.create(
4653
package=wrong_content.pulp_href,
47-
file_url=package.provenance_url,
54+
file_url=prov_url,
4855
)
4956
with pytest.raises(PulpTaskError) as e:
5057
monitor_task(provenance.task)
5158
assert e.value.task.state == "failed"
52-
assert "twine-6.2.0.tar.gz != shelf-reader-0.1.tar.gz" in e.value.task.error["description"]
59+
assert "twine-6.2.0-py3-none-any.whl != twine-6.2.0.tar.gz" in e.value.task.error["description"]
5360

5461
# Test creating a provenance without verifying
5562
provenance = python_bindings.ContentProvenanceApi.create(
5663
package=wrong_content.pulp_href,
57-
file_url=package.provenance_url,
64+
file_url=twine_package.provenance_url,
5865
verify=False,
5966
)
6067
task = monitor_task(provenance.task)
61-
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[0])
68+
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[-1])
6269
assert provenance.package == wrong_content.pulp_href
70+
71+
72+
@pytest.mark.parallel
73+
def test_integrity_api(
74+
python_bindings,
75+
python_repo,
76+
python_distribution_factory,
77+
twine_package,
78+
python_content_factory,
79+
monitor_task,
80+
):
81+
"""
82+
Test the integrity API.
83+
"""
84+
content = python_content_factory(
85+
relative_path=twine_package.filename,
86+
repository=python_repo.pulp_href,
87+
url=twine_package.url,
88+
)
89+
provenance = python_bindings.ContentProvenanceApi.create(
90+
package=content.pulp_href,
91+
file_url=twine_package.provenance_url,
92+
repository=python_repo.pulp_href,
93+
)
94+
task = monitor_task(provenance.task)
95+
provenance = python_bindings.ContentProvenanceApi.read(task.created_resources[-1])
96+
97+
distro = python_distribution_factory(repository=python_repo.pulp_href)
98+
url = f"{distro.base_url}integrity/twine/6.2.0/{twine_package.filename}/provenance/"
99+
r = requests.get(url)
100+
assert r.status_code == 200
101+
assert r.json() == provenance.provenance

pulp_python/tests/functional/api/test_pypi_simple_json_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)