Skip to content

Commit 5f16888

Browse files
authored
Validate release-file URL metadata (#3014)
1 parent 0251065 commit 5f16888

5 files changed

Lines changed: 327 additions & 5 deletions

File tree

apps/downloads/api.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""REST API endpoints for downloads using Tastypie and Django REST Framework."""
22

3+
from django.core.exceptions import ValidationError as DjangoValidationError
34
from rest_framework import status, viewsets
45
from rest_framework.authentication import TokenAuthentication
56
from rest_framework.decorators import action
67
from rest_framework.response import Response
78
from tastypie import fields
89
from tastypie.constants import ALL, ALL_WITH_RELATIONS
910
from tastypie.exceptions import BadRequest
11+
from tastypie.validation import Validation
1012

1113
from apps.downloads.models import OS, Release, ReleaseFile
1214
from apps.downloads.serializers import OSSerializer, ReleaseFileSerializer, ReleaseSerializer
@@ -15,6 +17,20 @@
1517
from pydotorg.resources import GenericResource, OnlyPublishedAuthorization
1618

1719

20+
class ReleaseFileValidation(Validation):
21+
"""Tastypie validation for release-file URL relationships."""
22+
23+
def is_valid(self, bundle, request=None):
24+
"""Return validation errors for hydrated release-file writes."""
25+
if bundle.obj is None:
26+
return {}
27+
try:
28+
bundle.obj.clean()
29+
except DjangoValidationError as exc:
30+
return exc.message_dict
31+
return {}
32+
33+
1834
class OSResource(GenericResource):
1935
"""Tastypie resource for operating systems."""
2036

@@ -90,6 +106,7 @@ class Meta(GenericResource.Meta):
90106
queryset = ReleaseFile.objects.all()
91107
resource_name = "downloads/release_file"
92108
list_allowed_methods = ["get", "post", "delete"]
109+
validation = ReleaseFileValidation()
93110
fields = [
94111
"name",
95112
"slug",

apps/downloads/models.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@
1919
from fastly.utils import purge_surrogate_key, purge_url
2020

2121
DEFAULT_MARKUP_TYPE = getattr(settings, "DEFAULT_MARKUP_TYPE", "markdown")
22+
PYTHON_DOT_ORG_HTTPS_PREFIX = "https://www.python.org/"
23+
PYTHON_DOT_ORG_HTTP_PREFIX = "http://www.python.org/"
24+
RELEASE_FILE_URL_FIELDS = (
25+
"url",
26+
"gpg_signature_file",
27+
"sigstore_signature_file",
28+
"sigstore_cert_file",
29+
"sigstore_bundle_file",
30+
"sbom_spdx2_file",
31+
)
32+
RELEASE_FILE_SIDECAR_SUFFIXES = {
33+
"gpg_signature_file": ".asc",
34+
"sigstore_signature_file": ".sig",
35+
"sigstore_cert_file": ".crt",
36+
"sigstore_bundle_file": ".sigstore",
37+
"sbom_spdx2_file": ".spdx.json",
38+
}
39+
RELEASE_FILE_HTTPS_ERROR = "Release file URLs must begin with 'https://www.python.org/'."
2240

2341

2442
class OS(ContentManageable, NameSlugModel):
@@ -361,13 +379,46 @@ def condition_url_is_blank_or_python_dot_org(column: str):
361379
"""Conditions for a URLField column to force 'http[s]://python.org'."""
362380
return (
363381
models.Q(**{f"{column}__exact": ""})
364-
| models.Q(**{f"{column}__startswith": "https://www.python.org/"})
382+
| models.Q(**{f"{column}__startswith": PYTHON_DOT_ORG_HTTPS_PREFIX})
365383
# Older releases allowed 'http://'. 'https://' is required at
366384
# the API level, so shouldn't show up in newer releases.
367-
| models.Q(**{f"{column}__startswith": "http://www.python.org/"})
385+
| models.Q(**{f"{column}__startswith": PYTHON_DOT_ORG_HTTP_PREFIX})
368386
)
369387

370388

389+
def validate_release_file_urls(release_file):
390+
"""Validate current ReleaseFile URL writes without rejecting unchanged legacy rows."""
391+
values = {}
392+
for field_name in RELEASE_FILE_URL_FIELDS:
393+
values[field_name] = getattr(release_file, field_name) or ""
394+
395+
previous_values = None
396+
if release_file.pk is not None:
397+
release_file_model = type(release_file)
398+
previous_values_qs = release_file_model.objects.filter(pk=release_file.pk)
399+
previous_values = previous_values_qs.values(*RELEASE_FILE_URL_FIELDS).first()
400+
errors = {}
401+
402+
for field_name, value in values.items():
403+
if not value or value.startswith(PYTHON_DOT_ORG_HTTPS_PREFIX):
404+
continue
405+
if previous_values is None or value != (previous_values[field_name] or ""):
406+
errors.setdefault(field_name, []).append(RELEASE_FILE_HTTPS_ERROR)
407+
408+
artifact_url = values["url"]
409+
if artifact_url:
410+
for field_name, suffix in RELEASE_FILE_SIDECAR_SUFFIXES.items():
411+
sidecar_url = values[field_name]
412+
expected_url = f"{artifact_url}{suffix}"
413+
if not sidecar_url or sidecar_url == expected_url:
414+
continue
415+
message = f"Sidecar URL must match the artifact URL plus '{suffix}'."
416+
errors.setdefault(field_name, []).append(message)
417+
418+
if errors:
419+
raise ValidationError(errors)
420+
421+
371422
class ReleaseFile(ContentManageable, NameSlugModel):
372423
"""Individual files in a release.
373424
@@ -395,6 +446,11 @@ class ReleaseFile(ContentManageable, NameSlugModel):
395446
filesize = models.IntegerField(default=0)
396447
download_button = models.BooleanField(default=False, help_text="Use for the supernav download button for this OS")
397448

449+
def clean(self):
450+
"""Validate release-file URL relationships."""
451+
super().clean()
452+
validate_release_file_urls(self)
453+
398454
def validate_unique(self, exclude=None):
399455
"""Ensure only one release file per OS has the download button enabled."""
400456
if self.download_button and self.release_id:

apps/downloads/serializers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""DRF serializers for the downloads API."""
22

3+
import copy
4+
5+
from django.core.exceptions import ValidationError as DjangoValidationError
36
from rest_framework import serializers
47

58
from apps.downloads.models import OS, Release, ReleaseFile
@@ -40,6 +43,24 @@ class Meta:
4043
class ReleaseFileSerializer(serializers.HyperlinkedModelSerializer):
4144
"""Serializer for release file data."""
4245

46+
def validate(self, attrs):
47+
"""Validate release-file URL relationships."""
48+
attrs = super().validate(attrs)
49+
release_file = self._release_file_for_validation(attrs)
50+
try:
51+
release_file.clean()
52+
except DjangoValidationError as exc:
53+
raise serializers.ValidationError(exc.message_dict) from exc
54+
return attrs
55+
56+
def _release_file_for_validation(self, attrs):
57+
if self.instance is None:
58+
return ReleaseFile(**attrs)
59+
release_file = copy.copy(self.instance)
60+
for attr, value in attrs.items():
61+
setattr(release_file, attr, value)
62+
return release_file
63+
4364
class Meta:
4465
"""Meta configuration for ReleaseFileSerializer."""
4566

apps/downloads/tests/test_models.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
11
import datetime as dt
22
from unittest.mock import patch
33

4+
from django.core.exceptions import ValidationError
45
from django.db import IntegrityError, transaction
56
from django.db.models import URLField
67

7-
from apps.downloads.models import OS, Release, ReleaseFile
8+
from apps.downloads.models import (
9+
OS,
10+
RELEASE_FILE_SIDECAR_SUFFIXES,
11+
RELEASE_FILE_URL_FIELDS,
12+
Release,
13+
ReleaseFile,
14+
)
815
from apps.downloads.tests.base import BaseDownloadTests
916

1017

18+
def release_file_url_field_names():
19+
return tuple(
20+
field.name
21+
for field in ReleaseFile._meta.get_fields() # noqa: SLF001
22+
if isinstance(field, URLField)
23+
)
24+
25+
1126
class DownloadModelTests(BaseDownloadTests):
1227
def test_stringification(self):
1328
self.assertEqual(str(self.osx), "macOS")
@@ -300,7 +315,7 @@ def test_release_file_urls_not_python_dot_org(self):
300315
with self.subTest(field.name), transaction.atomic():
301316
kwargs = {
302317
"url": "https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe",
303-
# field.name may be 'url', but will replace the default value.
318+
# field.name may be "url", but will replace the default value.
304319
field.name: "https://notpython.com/python-9.7.2.txt",
305320
}
306321

@@ -311,3 +326,96 @@ def test_release_file_urls_not_python_dot_org(self):
311326
name="Windows installer draft",
312327
**kwargs,
313328
)
329+
330+
def test_release_file_rejects_new_http_urls(self):
331+
for field_name in RELEASE_FILE_URL_FIELDS:
332+
with self.subTest(field_name):
333+
kwargs = {
334+
"url": "https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe",
335+
# field_name may be 'url', but will replace the default value.
336+
field_name: "http://www.python.org/ftp/python/9.7.2/python-9.7.2.exe",
337+
}
338+
release_file = ReleaseFile(
339+
os=self.windows,
340+
release=self.draft_release,
341+
name="Windows installer draft",
342+
slug=f"windows-installer-draft-{field_name}",
343+
**kwargs,
344+
)
345+
346+
with self.assertRaises(ValidationError) as cm:
347+
release_file.full_clean()
348+
self.assertIn(field_name, cm.exception.message_dict)
349+
350+
def test_release_file_url_fields_cover_model_url_fields(self):
351+
self.assertEqual(RELEASE_FILE_URL_FIELDS, release_file_url_field_names())
352+
353+
def test_release_file_sidecar_suffixes_cover_sidecar_url_fields(self):
354+
sidecar_field_names = set(RELEASE_FILE_URL_FIELDS) - {"url"}
355+
356+
self.assertEqual(set(RELEASE_FILE_SIDECAR_SUFFIXES), sidecar_field_names)
357+
358+
def test_release_file_allows_existing_http_urls_to_be_edited(self):
359+
release_file = ReleaseFile.objects.create(
360+
os=self.windows,
361+
release=self.draft_release,
362+
name="Windows installer draft",
363+
url="http://www.python.org/ftp/python/9.7.2/python-9.7.2.exe",
364+
)
365+
366+
release_file.description = "Updated legacy metadata"
367+
368+
release_file.full_clean()
369+
370+
def test_release_file_rejects_existing_mismatched_sidecar_urls(self):
371+
artifact_url = "https://www.python.org/ftp/python/9.7.2/Python-9.7.2-sidecar.tgz"
372+
release_file = ReleaseFile.objects.create(
373+
os=self.linux,
374+
release=self.draft_release,
375+
name="Source tarball draft",
376+
slug="source-tarball-draft-mismatch",
377+
url=artifact_url,
378+
gpg_signature_file=artifact_url.replace("9.7.2", "9.7.1") + ".asc",
379+
)
380+
381+
release_file.description = "Updated metadata"
382+
383+
with self.assertRaises(ValidationError) as cm:
384+
release_file.full_clean()
385+
self.assertIn("gpg_signature_file", cm.exception.message_dict)
386+
387+
def test_release_file_sidecar_urls_must_extend_artifact_url(self):
388+
artifact_url = "https://www.python.org/ftp/python/9.7.2/Python-9.7.2-sidecar.tgz"
389+
390+
for field_name, suffix in RELEASE_FILE_SIDECAR_SUFFIXES.items():
391+
with self.subTest(field_name):
392+
wrong_artifact_url = artifact_url.replace("9.7.2", "9.7.1")
393+
release_file = ReleaseFile(
394+
os=self.linux,
395+
release=self.draft_release,
396+
name="Source tarball draft",
397+
slug=f"source-tarball-draft-{field_name}",
398+
url=artifact_url,
399+
**{field_name: f"{wrong_artifact_url}{suffix}"},
400+
)
401+
402+
with self.assertRaises(ValidationError) as cm:
403+
release_file.full_clean()
404+
self.assertIn(field_name, cm.exception.message_dict)
405+
406+
def test_release_file_accepts_sidecar_urls_for_same_artifact(self):
407+
artifact_url = "https://www.python.org/ftp/python/9.7.2/Python-9.7.2-sidecar.tgz"
408+
sidecar_urls = {}
409+
for field_name, suffix in RELEASE_FILE_SIDECAR_SUFFIXES.items():
410+
sidecar_urls[field_name] = f"{artifact_url}{suffix}"
411+
412+
release_file = ReleaseFile(
413+
os=self.linux,
414+
release=self.draft_release,
415+
name="Source tarball draft",
416+
slug="source-tarball-draft",
417+
url=artifact_url,
418+
**sidecar_urls,
419+
)
420+
421+
release_file.full_clean()

0 commit comments

Comments
 (0)