Skip to content

Commit f016b77

Browse files
authored
Merge pull request #3 from ActiveState/BE-4718-cve-2024-6345
CVE-2024-6345 Cherry Pick fix 88807c7
2 parents 49fec9f + 403f606 commit f016b77

File tree

5 files changed

+141
-130
lines changed

5 files changed

+141
-130
lines changed

changelog.d/4332.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Modernized and refactored VCS handling in package_index.

setup.cfg

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,19 @@ testing =
7070
ini2toml[lite]>=0.9
7171
tomli-w>=1.0.0
7272
pytest-timeout
73-
pytest-perf
73+
pytest-perf; \
74+
# workaround for jaraco/inflect#195, pydantic/pydantic-core#773 (see #3986)
75+
sys_platform != "cygwin"
76+
# for tools/finalize.py
77+
jaraco.develop >= 7.21; python_version >= "3.9" and sys_platform != "cygwin"
78+
; pytest-home >= 0.5
79+
; mypy==1.9 # pin mypy version so a new version doesn't suddenly cause the CI to fail
80+
mypy
81+
# No Python 3.11 dependencies require tomli, but needed for type-checking since we import it directly
82+
tomli
83+
# No Python 3.12 dependencies require importlib_metadata, but needed for type-checking since we import it directly
84+
importlib_metadata
85+
pytest-subprocess
7486

7587
testing-integration =
7688
pytest

setuptools/package_index.py

Lines changed: 99 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,33 @@
11
"""PyPI and direct package downloading."""
22

3-
import sys
4-
import os
5-
import re
6-
import io
7-
import shutil
8-
import socket
93
import base64
10-
import hashlib
11-
import itertools
124
import configparser
5+
import hashlib
136
import html
147
import http.client
8+
import io
9+
import itertools
10+
import os
11+
import re
12+
import shutil
13+
import socket
14+
import subprocess
15+
import sys
16+
import urllib.error
1517
import urllib.parse
1618
import urllib.request
17-
import urllib.error
18-
from functools import wraps
19-
20-
import setuptools
21-
from pkg_resources import (
22-
CHECKOUT_DIST,
23-
Distribution,
24-
BINARY_DIST,
25-
normalize_path,
26-
SOURCE_DIST,
27-
Environment,
28-
find_distributions,
29-
safe_name,
30-
safe_version,
31-
to_filename,
32-
Requirement,
33-
DEVELOP_DIST,
34-
EGG_DIST,
35-
parse_version,
36-
)
3719
from distutils import log
3820
from distutils.errors import DistutilsError
3921
from fnmatch import translate
40-
from setuptools.wheel import Wheel
41-
from setuptools.extern.more_itertools import unique_everseen
22+
from functools import wraps
4223

24+
import setuptools
25+
from pkg_resources import (BINARY_DIST, CHECKOUT_DIST, DEVELOP_DIST, EGG_DIST,
26+
SOURCE_DIST, Distribution, Environment, Requirement,
27+
find_distributions, normalize_path, parse_version, safe_name,
28+
safe_version, to_filename)
29+
from setuptools.extern.more_itertools import unique_everseen
30+
from setuptools.wheel import Wheel
4331

4432
EGG_FRAGMENT = re.compile(r'^egg=([-A-Za-z0-9_.+!]+)$')
4533
HREF = re.compile(r"""href\s*=\s*['"]?([^'"> ]+)""", re.I)
@@ -195,7 +183,7 @@ def interpret_distro_name(
195183
'-'.join(parts[p:]),
196184
py_version=py_version,
197185
precedence=precedence,
198-
platform=platform
186+
platform=platform,
199187
)
200188

201189

@@ -305,7 +293,7 @@ def __init__(
305293
ca_bundle=None,
306294
verify_ssl=True,
307295
*args,
308-
**kw
296+
**kw,
309297
):
310298
super().__init__(*args, **kw)
311299
self.index_url = index_url + "/"[: not index_url.endswith('/')]
@@ -586,7 +574,7 @@ def download(self, spec, tmpdir):
586574
scheme = URL_SCHEME(spec)
587575
if scheme:
588576
# It's a url, download it to tmpdir
589-
found = self._download_url(scheme.group(1), spec, tmpdir)
577+
found = self._download_url(spec, tmpdir)
590578
base, fragment = egg_info_for_url(spec)
591579
if base.endswith('.py'):
592580
found = self.gen_setup(found, fragment, tmpdir)
@@ -813,7 +801,7 @@ def open_url(self, url, warning=None): # noqa: C901 # is too complex (12)
813801
else:
814802
raise DistutilsError("Download error for %s: %s" % (url, v)) from v
815803

816-
def _download_url(self, scheme, url, tmpdir):
804+
def _download_url(self, url, tmpdir):
817805
# Determine download filename
818806
#
819807
name, fragment = egg_info_for_url(url)
@@ -828,19 +816,59 @@ def _download_url(self, scheme, url, tmpdir):
828816

829817
filename = os.path.join(tmpdir, name)
830818

831-
# Download the file
832-
#
833-
if scheme == 'svn' or scheme.startswith('svn+'):
834-
return self._download_svn(url, filename)
835-
elif scheme == 'git' or scheme.startswith('git+'):
836-
return self._download_git(url, filename)
837-
elif scheme.startswith('hg+'):
838-
return self._download_hg(url, filename)
839-
elif scheme == 'file':
840-
return urllib.request.url2pathname(urllib.parse.urlparse(url)[2])
841-
else:
842-
self.url_ok(url, True) # raises error if not allowed
843-
return self._attempt_download(url, filename)
819+
return self._download_vcs(url, filename) or self._download_other(url, filename)
820+
821+
@staticmethod
822+
def _resolve_vcs(url):
823+
"""
824+
>>> rvcs = PackageIndex._resolve_vcs
825+
>>> rvcs('git+http://foo/bar')
826+
'git'
827+
>>> rvcs('hg+https://foo/bar')
828+
'hg'
829+
>>> rvcs('git:myhost')
830+
'git'
831+
>>> rvcs('hg:myhost')
832+
>>> rvcs('http://foo/bar')
833+
"""
834+
scheme = urllib.parse.urlsplit(url).scheme
835+
pre, sep, post = scheme.partition('+')
836+
# svn and git have their own protocol; hg does not
837+
allowed = set(['svn', 'git'] + ['hg'] * bool(sep))
838+
return next(iter({pre} & allowed), None)
839+
840+
def _download_vcs(self, url, spec_filename):
841+
vcs = self._resolve_vcs(url)
842+
if not vcs:
843+
return
844+
if vcs == 'svn':
845+
raise DistutilsError(
846+
f"Invalid config, SVN download is not supported: {url}"
847+
)
848+
849+
filename, _, _ = spec_filename.partition('#')
850+
url, rev = self._vcs_split_rev_from_url(url)
851+
852+
self.info(f"Doing {vcs} clone from {url} to {filename}")
853+
subprocess.check_call([vcs, 'clone', '--quiet', url, filename])
854+
855+
co_commands = dict(
856+
git=[vcs, '-C', filename, 'checkout', '--quiet', rev],
857+
hg=[vcs, '--cwd', filename, 'up', '-C', '-r', rev, '-q'],
858+
)
859+
if rev is not None:
860+
self.info(f"Checking out {rev}")
861+
subprocess.check_call(co_commands[vcs])
862+
863+
return filename
864+
865+
def _download_other(self, url, filename):
866+
scheme = urllib.parse.urlsplit(url).scheme
867+
if scheme == 'file': # pragma: no cover
868+
return urllib.request.url2pathname(urllib.parse.urlparse(url).path)
869+
# raise error if not allowed
870+
self.url_ok(url, True)
871+
return self._attempt_download(url, filename)
844872

845873
def scan_url(self, url):
846874
self.process_url(url, True)
@@ -856,64 +884,37 @@ def _invalid_download_html(self, url, headers, filename):
856884
os.unlink(filename)
857885
raise DistutilsError(f"Unexpected HTML page found at {url}")
858886

859-
def _download_svn(self, url, _filename):
860-
raise DistutilsError(f"Invalid config, SVN download is not supported: {url}")
861-
862887
@staticmethod
863-
def _vcs_split_rev_from_url(url, pop_prefix=False):
864-
scheme, netloc, path, query, frag = urllib.parse.urlsplit(url)
888+
def _vcs_split_rev_from_url(url):
889+
"""
890+
Given a possible VCS URL, return a clean URL and resolved revision if any.
891+
892+
>>> vsrfu = PackageIndex._vcs_split_rev_from_url
893+
>>> vsrfu('git+https://github.com/pypa/[email protected]#egg-info=setuptools')
894+
('https://github.com/pypa/setuptools', 'v69.0.0')
895+
>>> vsrfu('git+https://github.com/pypa/setuptools#egg-info=setuptools')
896+
('https://github.com/pypa/setuptools', None)
897+
>>> vsrfu('http://foo/bar')
898+
('http://foo/bar', None)
899+
"""
900+
parts = urllib.parse.urlsplit(url)
865901

866-
scheme = scheme.split('+', 1)[-1]
902+
clean_scheme = parts.scheme.split('+', 1)[-1]
867903

868904
# Some fragment identification fails
869-
path = path.split('#', 1)[0]
905+
no_fragment_path, _, _ = parts.path.partition('#')
870906

871-
rev = None
872-
if '@' in path:
873-
path, rev = path.rsplit('@', 1)
907+
pre, sep, post = no_fragment_path.rpartition('@')
908+
clean_path, rev = (pre, post) if sep else (post, None)
874909

875-
# Also, discard fragment
876-
url = urllib.parse.urlunsplit((scheme, netloc, path, query, ''))
910+
resolved = parts._replace(
911+
scheme=clean_scheme,
912+
path=clean_path,
913+
# discard the fragment
914+
fragment='',
915+
).geturl()
877916

878-
return url, rev
879-
880-
def _download_git(self, url, filename):
881-
filename = filename.split('#', 1)[0]
882-
url, rev = self._vcs_split_rev_from_url(url, pop_prefix=True)
883-
884-
self.info("Doing git clone from %s to %s", url, filename)
885-
os.system("git clone --quiet %s %s" % (url, filename))
886-
887-
if rev is not None:
888-
self.info("Checking out %s", rev)
889-
os.system(
890-
"git -C %s checkout --quiet %s"
891-
% (
892-
filename,
893-
rev,
894-
)
895-
)
896-
897-
return filename
898-
899-
def _download_hg(self, url, filename):
900-
filename = filename.split('#', 1)[0]
901-
url, rev = self._vcs_split_rev_from_url(url, pop_prefix=True)
902-
903-
self.info("Doing hg clone from %s to %s", url, filename)
904-
os.system("hg clone --quiet %s %s" % (url, filename))
905-
906-
if rev is not None:
907-
self.info("Updating to %s", rev)
908-
os.system(
909-
"hg --cwd %s up -C -r %s -q"
910-
% (
911-
filename,
912-
rev,
913-
)
914-
)
915-
916-
return filename
917+
return resolved, rev
917918

918919
def debug(self, msg, *args):
919920
log.debug(msg, *args)

setuptools/tests/test_packageindex.py

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import urllib.request
66
import urllib.error
77
import http.client
8-
from unittest import mock
98

109
import pytest
1110

@@ -186,49 +185,46 @@ def test_egg_fragment(self):
186185
assert dists[0].version == ''
187186
assert dists[1].version == vc
188187

189-
def test_download_git_with_rev(self, tmpdir):
188+
def test_download_git_with_rev(self, tmp_path, fp):
190189
url = 'git+https://github.example/group/project@master#egg=foo'
191190
index = setuptools.package_index.PackageIndex()
192191

193-
with mock.patch("os.system") as os_system_mock:
194-
result = index.download(url, str(tmpdir))
192+
expected_dir = tmp_path / 'project@master'
193+
fp.register([
194+
'git',
195+
'clone',
196+
'--quiet',
197+
'https://github.example/group/project',
198+
expected_dir,
199+
])
200+
fp.register(['git', '-C', expected_dir, 'checkout', '--quiet', 'master'])
195201

196-
os_system_mock.assert_called()
202+
result = index.download(url, tmp_path)
197203

198-
expected_dir = str(tmpdir / 'project@master')
199-
expected = (
200-
'git clone --quiet ' 'https://github.example/group/project {expected_dir}'
201-
).format(**locals())
202-
first_call_args = os_system_mock.call_args_list[0][0]
203-
assert first_call_args == (expected,)
204+
assert result == str(expected_dir)
205+
assert len(fp.calls) == 2
204206

205-
tmpl = 'git -C {expected_dir} checkout --quiet master'
206-
expected = tmpl.format(**locals())
207-
assert os_system_mock.call_args_list[1][0] == (expected,)
208-
assert result == expected_dir
209-
210-
def test_download_git_no_rev(self, tmpdir):
207+
def test_download_git_no_rev(self, tmp_path, fp):
211208
url = 'git+https://github.example/group/project#egg=foo'
212209
index = setuptools.package_index.PackageIndex()
213210

214-
with mock.patch("os.system") as os_system_mock:
215-
result = index.download(url, str(tmpdir))
216-
217-
os_system_mock.assert_called()
218-
219-
expected_dir = str(tmpdir / 'project')
220-
expected = (
221-
'git clone --quiet ' 'https://github.example/group/project {expected_dir}'
222-
).format(**locals())
223-
os_system_mock.assert_called_once_with(expected)
224-
225-
def test_download_svn(self, tmpdir):
211+
expected_dir = tmp_path / 'project'
212+
fp.register([
213+
'git',
214+
'clone',
215+
'--quiet',
216+
'https://github.example/group/project',
217+
expected_dir,
218+
])
219+
index.download(url, tmp_path)
220+
221+
def test_download_svn(self, tmp_path):
226222
url = 'svn+https://svn.example/project#egg=foo'
227223
index = setuptools.package_index.PackageIndex()
228224

229225
msg = r".*SVN download is not supported.*"
230226
with pytest.raises(distutils.errors.DistutilsError, match=msg):
231-
index.download(url, str(tmpdir))
227+
index.download(url, tmp_path)
232228

233229

234230
class TestContentCheckers:

tox.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ deps =
1010
# Ideally all the dependencies should be set as "extras"
1111
setenv =
1212
PYTHONWARNDEFAULTENCODING = 1
13-
SETUPTOOLS_ENFORCE_DEPRECATION = 1
13+
; SETUPTOOLS_ENFORCE_DEPRECATION = 1
14+
SETUPTOOLS_ENFORCE_DEPRECATION = false
1415
commands =
1516
pytest {posargs}
1617
usedevelop = True

0 commit comments

Comments
 (0)