Skip to content

Commit a59280c

Browse files
authored
Force 'https://www.python.org/' URLs for ReleaseFile properties (#2947)
1 parent 929affd commit a59280c

File tree

4 files changed

+116
-12
lines changed

4 files changed

+116
-12
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 5.2.11 on 2026-02-25 14:13
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("downloads", "0014_releasefile_sha256_sum"),
10+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11+
]
12+
13+
operations = [
14+
migrations.AddConstraint(
15+
model_name="releasefile",
16+
constraint=models.CheckConstraint(
17+
condition=models.Q(
18+
models.Q(
19+
("url__exact", ""),
20+
("url__startswith", "https://www.python.org/"),
21+
("url__startswith", "http://www.python.org/"),
22+
_connector="OR",
23+
),
24+
models.Q(
25+
("gpg_signature_file__exact", ""),
26+
("gpg_signature_file__startswith", "https://www.python.org/"),
27+
("gpg_signature_file__startswith", "http://www.python.org/"),
28+
_connector="OR",
29+
),
30+
models.Q(
31+
("sigstore_signature_file__exact", ""),
32+
("sigstore_signature_file__startswith", "https://www.python.org/"),
33+
("sigstore_signature_file__startswith", "http://www.python.org/"),
34+
_connector="OR",
35+
),
36+
models.Q(
37+
("sigstore_cert_file__exact", ""),
38+
("sigstore_cert_file__startswith", "https://www.python.org/"),
39+
("sigstore_cert_file__startswith", "http://www.python.org/"),
40+
_connector="OR",
41+
),
42+
models.Q(
43+
("sigstore_bundle_file__exact", ""),
44+
("sigstore_bundle_file__startswith", "https://www.python.org/"),
45+
("sigstore_bundle_file__startswith", "http://www.python.org/"),
46+
_connector="OR",
47+
),
48+
models.Q(
49+
("sbom_spdx2_file__exact", ""),
50+
("sbom_spdx2_file__startswith", "https://www.python.org/"),
51+
("sbom_spdx2_file__startswith", "http://www.python.org/"),
52+
_connector="OR",
53+
),
54+
),
55+
name="only_python_dot_org_urls",
56+
violation_error_message="All file URLs must begin with 'https://www.python.org/'",
57+
),
58+
),
59+
]

apps/downloads/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,17 @@ def update_boxes_on_release_file_delete(sender, instance, **kwargs):
357357
_update_boxes_for_release_file(instance)
358358

359359

360+
def condition_url_is_blank_or_python_dot_org(column: str):
361+
"""Conditions for a URLField column to force 'http[s]://python.org'."""
362+
return (
363+
models.Q(**{f"{column}__exact": ""})
364+
| models.Q(**{f"{column}__startswith": "https://www.python.org/"})
365+
# Older releases allowed 'http://'. 'https://' is required at
366+
# the API level, so shouldn't show up in newer releases.
367+
| models.Q(**{f"{column}__startswith": "http://www.python.org/"})
368+
)
369+
370+
360371
class ReleaseFile(ContentManageable, NameSlugModel):
361372
"""Individual files in a release.
362373
@@ -406,4 +417,16 @@ class Meta:
406417
condition=models.Q(download_button=True),
407418
name="only_one_download_per_os_per_release",
408419
),
420+
models.CheckConstraint(
421+
condition=(
422+
condition_url_is_blank_or_python_dot_org("url")
423+
& condition_url_is_blank_or_python_dot_org("gpg_signature_file")
424+
& condition_url_is_blank_or_python_dot_org("sigstore_signature_file")
425+
& condition_url_is_blank_or_python_dot_org("sigstore_cert_file")
426+
& condition_url_is_blank_or_python_dot_org("sigstore_bundle_file")
427+
& condition_url_is_blank_or_python_dot_org("sbom_spdx2_file")
428+
),
429+
name="only_python_dot_org_urls",
430+
violation_error_message="All file URLs must begin with 'https://www.python.org/'",
431+
),
409432
]

apps/downloads/tests/base.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,22 @@ def setUp(self):
3636
release=self.release_275,
3737
name="Windows x86 MSI Installer (2.7.5)",
3838
description="Windows binary -- does not include source",
39-
url="ftp/python/2.7.5/python-2.7.5.msi",
39+
url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.msi",
4040
)
4141
self.release_275_windows_64bit = ReleaseFile.objects.create(
4242
os=self.windows,
4343
release=self.release_275,
4444
name="Windows X86-64 MSI Installer (2.7.5)",
4545
description="Windows AMD64 / Intel 64 / X86-64 binary -- does not include source",
46-
url="ftp/python/2.7.5/python-2.7.5.amd64.msi",
46+
url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.amd64.msi",
4747
)
4848

4949
self.release_275_osx = ReleaseFile.objects.create(
5050
os=self.osx,
5151
release=self.release_275,
5252
name="Mac OSX 64-bit/32-bit",
5353
description="Mac OS X 10.6 and later",
54-
url="ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg",
54+
url="https://www.python.org/ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg",
5555
)
5656

5757
self.release_275_linux = ReleaseFile.objects.create(
@@ -60,7 +60,7 @@ def setUp(self):
6060
release=self.release_275,
6161
is_source=True,
6262
description="Gzipped source",
63-
url="ftp/python/2.7.5/Python-2.7.5.tgz",
63+
url="https://www.python.org/ftp/python/2.7.5/Python-2.7.5.tgz",
6464
filesize=12345678,
6565
)
6666

@@ -77,7 +77,7 @@ def setUp(self):
7777
release=self.draft_release,
7878
is_source=True,
7979
description="Gzipped source",
80-
url="ftp/python/9.7.2/Python-9.7.2.tgz",
80+
url="https://www.python.org/ftp/python/9.7.2/Python-9.7.2.tgz",
8181
)
8282

8383
self.hidden_release = Release.objects.create(

apps/downloads/tests/test_models.py

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

4-
from apps.downloads.models import Release, ReleaseFile
4+
from django.db import IntegrityError, transaction
5+
from django.db.models import URLField
6+
7+
from apps.downloads.models import OS, Release, ReleaseFile
58
from apps.downloads.tests.base import BaseDownloadTests
69

710

@@ -160,7 +163,7 @@ def test_update_supernav(self):
160163
release=self.python_3,
161164
slug=slug,
162165
name="Python 3.10",
163-
url=f"/ftp/python/{slug}.zip",
166+
url=f"https://www.python.org/ftp/python/{slug}.zip",
164167
download_button=True,
165168
)
166169

@@ -179,7 +182,7 @@ def test_update_supernav(self):
179182
os=self.windows,
180183
release=release,
181184
name="MSIX",
182-
url="/ftp/python/pymanager/pymanager-25.0.msix",
185+
url="https://www.python.org/ftp/python/pymanager/pymanager-25.0.msix",
183186
download_button=True,
184187
)
185188

@@ -199,7 +202,7 @@ def test_update_supernav_skips_os_without_files(self):
199202
"""
200203
# Arrange
201204
from apps.boxes.models import Box
202-
from apps.downloads.models import OS, update_supernav
205+
from apps.downloads.models import update_supernav
203206

204207
# Create an OS without any release files
205208
OS.objects.create(name="Android", slug="android")
@@ -215,7 +218,7 @@ def test_update_supernav_skips_os_without_files(self):
215218
release=self.python_3,
216219
slug=slug,
217220
name="Python 3.10",
218-
url=f"/ftp/python/{slug}.zip",
221+
url=f"https://www.python.org/ftp/python/{slug}.zip",
219222
download_button=True,
220223
)
221224

@@ -247,7 +250,7 @@ def test_release_file_save_triggers_box_updates(self, mock_home, mock_sources, m
247250
os=self.windows,
248251
release=self.python_3,
249252
name="Windows installer",
250-
url="/ftp/python/3.10.19/python-3.10.19.exe",
253+
url="https://www.python.org/ftp/python/3.10.19/python-3.10.19.exe",
251254
download_button=True,
252255
)
253256

@@ -268,7 +271,7 @@ def test_release_file_save_skips_unpublished_release(self, mock_home, mock_sourc
268271
os=self.windows,
269272
release=self.draft_release,
270273
name="Windows installer draft",
271-
url="/ftp/python/9.7.2/python-9.7.2.exe",
274+
url="https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe",
272275
)
273276

274277
mock_supernav.assert_not_called()
@@ -289,3 +292,22 @@ def test_release_file_delete_triggers_box_updates(self, mock_home, mock_sources,
289292
mock_supernav.assert_called()
290293
mock_sources.assert_called()
291294
mock_home.assert_called()
295+
296+
def test_release_file_urls_not_python_dot_org(self):
297+
for field in ReleaseFile._meta.get_fields(): # noqa: SLF001
298+
if not isinstance(field, URLField):
299+
continue
300+
with self.subTest(field.name), transaction.atomic():
301+
kwargs = {
302+
"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.
304+
field.name: "https://notpython.com/python-9.7.2.txt",
305+
}
306+
307+
with self.assertRaises(IntegrityError):
308+
ReleaseFile.objects.create(
309+
os=self.windows,
310+
release=self.draft_release,
311+
name="Windows installer draft",
312+
**kwargs,
313+
)

0 commit comments

Comments
 (0)