Skip to content

Commit 5a2d6f8

Browse files
authored
Validate release version in filename (pypi#15795)
* Use meta.version instead of POST data * Use parse_wheel_filename and parse_sdist_filename * Add a test for mismatched filename/meta version * Linting * Remove version field from UploadForm * Add back tests around invalid versions
1 parent 4ff4a8e commit 5a2d6f8

File tree

4 files changed

+160
-99
lines changed

4 files changed

+160
-99
lines changed

tests/unit/forklift/test_forms.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,12 @@
1111
# limitations under the License.
1212

1313

14-
import pretend
1514
import pytest
1615

1716
from webob.multidict import MultiDict
1817
from wtforms.validators import ValidationError
1918

20-
from warehouse.forklift.forms import UploadForm, _validate_pep440_version
21-
22-
23-
class TestValidation:
24-
@pytest.mark.parametrize("version", ["1.0", "30a1", "1!1", "1.0-1", "v1.0"])
25-
def test_validates_valid_pep440_version(self, version):
26-
form, field = pretend.stub(), pretend.stub(data=version)
27-
_validate_pep440_version(form, field)
28-
29-
@pytest.mark.parametrize("version", ["dog", "1.0.dev.a1"])
30-
def test_validates_invalid_pep440_version(self, version):
31-
form, field = pretend.stub(), pretend.stub(data=version)
32-
with pytest.raises(ValidationError):
33-
_validate_pep440_version(form, field)
19+
from warehouse.forklift.forms import UploadForm
3420

3521

3622
class TestUploadForm:

tests/unit/forklift/test_legacy.py

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -482,21 +482,40 @@ def test_fails_invalid_version(self, pyramid_config, pyramid_request, version):
482482
),
483483
# version errors.
484484
(
485-
{"metadata_version": "1.2", "name": "example"},
486-
"'' is an invalid value for Version. "
487-
"Error: This field is required. "
488-
"See "
489-
"https://packaging.python.org/specifications/core-metadata"
490-
" for more information.",
485+
{
486+
"metadata_version": "1.2",
487+
"name": "example",
488+
"version": "",
489+
"md5_digest": "bad",
490+
"filetype": "sdist",
491+
},
492+
"'version' is a required field. See "
493+
"https://packaging.python.org/specifications/core-metadata for "
494+
"more information.",
491495
),
492496
(
493-
{"metadata_version": "1.2", "name": "example", "version": "dog"},
494-
"'dog' is an invalid value for Version. "
495-
"Error: Start and end with a letter or numeral "
496-
"containing only ASCII numeric and '.', '_' and '-'. "
497-
"See "
498-
"https://packaging.python.org/specifications/core-metadata"
499-
" for more information.",
497+
{
498+
"metadata_version": "1.2",
499+
"name": "example",
500+
"version": "dog",
501+
"md5_digest": "bad",
502+
"filetype": "sdist",
503+
},
504+
"'dog' is invalid for 'version'. See "
505+
"https://packaging.python.org/specifications/core-metadata for "
506+
"more information.",
507+
),
508+
(
509+
{
510+
"metadata_version": "1.2",
511+
"name": "example",
512+
"version": "1.0.dev.a1",
513+
"md5_digest": "bad",
514+
"filetype": "sdist",
515+
},
516+
"'1.0.dev.a1' is invalid for 'version'. See "
517+
"https://packaging.python.org/specifications/core-metadata for "
518+
"more information.",
500519
),
501520
# filetype/pyversion errors.
502521
(
@@ -2000,11 +2019,18 @@ def test_upload_fails_with_diff_filename_same_blake2(
20002019
("no-way-{version}.tar.gz", "sdist", "no"),
20012020
("no_way-{version}-py3-none-any.whl", "bdist_wheel", "no"),
20022021
# multiple delimiters
2003-
("foo__bar-{version}-py3-none-any.whl", "bdist_wheel", "foo-.bar"),
2022+
("foobar-{version}-py3-none-any.whl", "bdist_wheel", "foo-.bar"),
20042023
],
20052024
)
2006-
def test_upload_fails_with_wrong_filename(
2007-
self, pyramid_config, db_request, metrics, filename, filetype, project_name
2025+
def test_upload_fails_with_wrong_filename_project_name(
2026+
self,
2027+
monkeypatch,
2028+
pyramid_config,
2029+
db_request,
2030+
metrics,
2031+
filename,
2032+
filetype,
2033+
project_name,
20082034
):
20092035
user = UserFactory.create()
20102036
pyramid_config.testing_securitypolicy(identity=user)
@@ -2020,6 +2046,7 @@ def test_upload_fails_with_wrong_filename(
20202046
IFileStorage: storage_service,
20212047
IMetricsService: metrics,
20222048
}.get(svc)
2049+
monkeypatch.setattr(legacy, "_is_valid_dist_file", lambda *a, **kw: True)
20232050

20242051
db_request.POST = MultiDict(
20252052
{
@@ -2055,6 +2082,57 @@ def test_upload_fails_with_wrong_filename(
20552082
)
20562083
)
20572084

2085+
@pytest.mark.parametrize(
2086+
"filename", ["wutang-6.6.6.tar.gz", "wutang-6.6.6-py3-none-any.whl"]
2087+
)
2088+
def test_upload_fails_with_wrong_filename_version(
2089+
self, monkeypatch, pyramid_config, db_request, metrics, filename
2090+
):
2091+
user = UserFactory.create()
2092+
pyramid_config.testing_securitypolicy(identity=user)
2093+
db_request.user = user
2094+
db_request.user_agent = "warehouse-tests/6.6.6"
2095+
EmailFactory.create(user=user)
2096+
project = ProjectFactory.create(name="wutang")
2097+
RoleFactory.create(user=user, project=project)
2098+
2099+
storage_service = pretend.stub(store=lambda path, filepath, meta: None)
2100+
db_request.find_service = lambda svc, name=None, context=None: {
2101+
IFileStorage: storage_service,
2102+
IMetricsService: metrics,
2103+
}.get(svc)
2104+
monkeypatch.setattr(legacy, "_is_valid_dist_file", lambda *a, **kw: True)
2105+
2106+
filetype = "sdist" if filename.endswith(".tar.gz") else "bdist_wheel"
2107+
db_request.POST = MultiDict(
2108+
{
2109+
"metadata_version": "1.2",
2110+
"name": project.name,
2111+
"version": "1.2.3",
2112+
"filetype": filetype,
2113+
"md5_digest": _TAR_GZ_PKG_MD5,
2114+
"pyversion": {
2115+
"bdist_wheel": "1.0",
2116+
"bdist_egg": "1.0",
2117+
"sdist": "source",
2118+
}[filetype],
2119+
"content": pretend.stub(
2120+
filename=filename,
2121+
file=io.BytesIO(_TAR_GZ_PKG_TESTDATA),
2122+
type="application/tar",
2123+
),
2124+
}
2125+
)
2126+
db_request.help_url = lambda **kw: "/the/help/url/"
2127+
2128+
with pytest.raises(HTTPBadRequest) as excinfo:
2129+
legacy.file_upload(db_request)
2130+
2131+
resp = excinfo.value
2132+
2133+
assert resp.status_code == 400
2134+
assert resp.status == ("400 Version in filename should be '1.2.3' not '6.6.6'.")
2135+
20582136
@pytest.mark.parametrize(
20592137
"filetype, extension",
20602138
[

warehouse/forklift/forms.py

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@
1212

1313
import re
1414

15-
import packaging.requirements
16-
import packaging.specifiers
17-
import packaging.utils
18-
import packaging.version
1915
import wtforms
2016
import wtforms.validators
2117

@@ -28,17 +24,6 @@
2824
}
2925

3026

31-
def _validate_pep440_version(form, field):
32-
# Check that this version is a valid PEP 440 version at all.
33-
try:
34-
packaging.version.parse(field.data)
35-
except packaging.version.InvalidVersion:
36-
raise wtforms.validators.ValidationError(
37-
"Start and end with a letter or numeral containing only "
38-
"ASCII numeric and '.', '_' and '-'."
39-
)
40-
41-
4227
# NOTE: This form validation runs prior to ensuring that the current identity
4328
# is authorized to upload for the given project, so it should not validate
4429
# against anything other than what the user themselves have provided.
@@ -47,8 +32,8 @@ def _validate_pep440_version(form, field):
4732
# occur elsewhere so that they can happen after we've authorized the request
4833
# to upload for the given project.
4934
class UploadForm(forms.Form):
50-
# The name and version fields are duplicated out of the general metadata handling,
51-
# to be part of the upload form as well so that we can use them prior to extracting
35+
# The name field is duplicated out of the general metadata handling, to be
36+
# part of the upload form as well so that we can use it prior to extracting
5237
# the metadata from the uploaded artifact.
5338
#
5439
# NOTE: We don't need to fully validate these values here, as we will be validating
@@ -68,17 +53,6 @@ class UploadForm(forms.Form):
6853
),
6954
],
7055
)
71-
version = wtforms.StringField(
72-
description="Version",
73-
validators=[
74-
wtforms.validators.InputRequired(),
75-
wtforms.validators.Regexp(
76-
r"^(?!\s).*(?<!\s)$",
77-
message="Can't have leading or trailing whitespace.",
78-
),
79-
_validate_pep440_version,
80-
],
81-
)
8256

8357
# File metadata
8458
pyversion = wtforms.StringField(validators=[wtforms.validators.Optional()])

warehouse/forklift/legacy.py

Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ def file_upload(request):
693693
release = (
694694
request.db.query(Release)
695695
.filter(
696-
(Release.project == project) & (Release.version == form.version.data)
696+
(Release.project == project) & (Release.version == str(meta.version))
697697
)
698698
.one()
699699
)
@@ -831,44 +831,6 @@ def file_upload(request):
831831
# Ensure the filename doesn't contain any characters that are too 🌶️spicy🥵
832832
_validate_filename(filename, filetype=form.filetype.data)
833833

834-
# Extract the project name from the filename and normalize it.
835-
filename_prefix = (
836-
# For wheels, the project name is normalized and won't contain hyphens, so
837-
# we can split on the first hyphen.
838-
filename.partition("-")[0]
839-
if filename.endswith(".whl")
840-
# For source releases, the version might contain a hyphen as a
841-
# post-release separator, so we get the prefix by removing the provided
842-
# version.
843-
# Per 625, the version should be normalized, but we aren't currently
844-
# enforcing this, so we permit a filename with either the exact
845-
# provided version if it contains a hyphen, or any version that doesn't
846-
# contain a hyphen.
847-
else (
848-
# A hyphen is being used for a post-release separator, so partition
849-
# the prefix twice
850-
filename.rpartition("-")[0].rpartition("-")[0]
851-
# Check if the provided version contains a hyphen and the same
852-
# version is being used in the filename
853-
if "-" in form.version.data
854-
and filename.endswith(f"-{form.version.data}.tar.gz")
855-
# The only hyphen should be between the prefix and the version, so
856-
# we only need to partition the prefix once
857-
else filename.rpartition("-")[0]
858-
)
859-
)
860-
861-
# Normalize the prefix in the filename. Eventually this should be unnecessary once
862-
# we become more restrictive in what we permit
863-
filename_prefix = filename_prefix.lower().replace(".", "_").replace("-", "_")
864-
865-
# Make sure that our filename matches the project that it is being uploaded to.
866-
if (prefix := project.normalized_name.replace("-", "_")) != filename_prefix:
867-
raise _exc_with_message(
868-
HTTPBadRequest,
869-
f"Start filename for {project.name!r} with {prefix!r}.",
870-
)
871-
872834
# Check the content type of what is being uploaded
873835
if not request.POST["content"].type or request.POST["content"].type.startswith(
874836
"image/"
@@ -994,10 +956,57 @@ def file_upload(request):
994956
if not _is_valid_dist_file(temporary_filename, form.filetype.data):
995957
raise _exc_with_message(HTTPBadRequest, "Invalid distribution file.")
996958

959+
# Check that the sdist filename is correct
960+
if filename.endswith(".tar.gz"):
961+
# Extract the project name and version from the filename and check it.
962+
# Per PEP 625, both should be normalized, but we aren't currently
963+
# enforcing this, so we permit a filename with a project name and
964+
# version that normalizes to be what we expect
965+
966+
name, version = packaging.utils.parse_sdist_filename(filename)
967+
968+
# The previous function fails to accomodate the edge case where
969+
# versions may contain hyphens, so we handle that here based on
970+
# what we were expecting
971+
if (
972+
meta.version.is_postrelease
973+
and packaging.utils.canonicalize_name(name) != meta.name
974+
):
975+
# The distribution is a source distribution, the version is a
976+
# postrelease, and the project name doesn't match, so
977+
# there may be a hyphen in the version. Split the filename on the
978+
# second to last hyphen instead.
979+
name = filename.rpartition("-")[0].rpartition("-")[0]
980+
version = packaging.version.Version(
981+
filename[len(name) + 1 : -len(".tar.gz")]
982+
)
983+
984+
# Normalize the prefix in the filename. Eventually this should be
985+
# unnecessary once we become more restrictive in what we permit
986+
filename_prefix = name.lower().replace(".", "_").replace("-", "_")
987+
988+
# Make sure that our filename matches the project that it is being
989+
# uploaded to.
990+
if (prefix := project.normalized_name.replace("-", "_")) != filename_prefix:
991+
raise _exc_with_message(
992+
HTTPBadRequest,
993+
f"Start filename for {project.name!r} with {prefix!r}.",
994+
)
995+
996+
# Make sure that the version in the filename matches the metadata
997+
if version != meta.version:
998+
raise _exc_with_message(
999+
HTTPBadRequest,
1000+
f"Version in filename should be {str(meta.version)!r} not "
1001+
f"{str(version)!r}.",
1002+
)
1003+
9971004
# Check that if it's a binary wheel, it's on a supported platform
9981005
if filename.endswith(".whl"):
9991006
try:
1000-
_, __, ___, tags = packaging.utils.parse_wheel_filename(filename)
1007+
name, version, ___, tags = packaging.utils.parse_wheel_filename(
1008+
filename
1009+
)
10011010
except packaging.utils.InvalidWheelFilename as e:
10021011
raise _exc_with_message(
10031012
HTTPBadRequest,
@@ -1012,6 +1021,20 @@ def file_upload(request):
10121021
f"platform tag '{tag.platform}'.",
10131022
)
10141023

1024+
if (canonical_name := packaging.utils.canonicalize_name(meta.name)) != name:
1025+
raise _exc_with_message(
1026+
HTTPBadRequest,
1027+
f"Start filename for {project.name!r} with "
1028+
f"{canonical_name.replace('-', '_')!r}.",
1029+
)
1030+
1031+
if meta.version != version:
1032+
raise _exc_with_message(
1033+
HTTPBadRequest,
1034+
f"Version in filename should be {str(meta.version)!r} not "
1035+
f"{str(version)!r}.",
1036+
)
1037+
10151038
"""
10161039
Extract METADATA file from a wheel and return it as a content.
10171040
The name of the .whl file is used to find the corresponding .dist-info dir.

0 commit comments

Comments
 (0)