Skip to content

Commit acdccb7

Browse files
authored
Merge branch 'main' into vendoring-updates
2 parents 7e80954 + ed05518 commit acdccb7

File tree

8 files changed

+215
-8
lines changed

8 files changed

+215
-8
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ jobs:
201201
python-version: ${{ matrix.python }}
202202
allow-prereleases: true
203203

204+
- name: Install SVN via Chocolatey
205+
shell: pwsh
206+
run: |
207+
choco install svn -y --no-progress
208+
echo "C:\Program Files (x86)\Subversion\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
209+
204210
- run: pip install nox
205211

206212
# Main check

news/13550.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
For Python versions that do not support PEP 706, pip will now raise an installation error for a
2+
source distribution when it includes a symlink that points outside the source distribution archive.

src/pip/_internal/build_env.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,14 @@ def install(
169169
args.append("--prefer-binary")
170170
args.append("--")
171171
args.extend(requirements)
172+
173+
identify_requirement = (
174+
f" for {for_req.name}" if for_req and for_req.name else ""
175+
)
172176
with open_spinner(f"Installing {kind}") as spinner:
173177
call_subprocess(
174178
args,
175-
command_desc=f"pip subprocess to install {kind}",
179+
command_desc=f"installing {kind}{identify_requirement}",
176180
spinner=spinner,
177181
)
178182

src/pip/_internal/exceptions.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,23 @@ class InstallationError(PipError):
190190
"""General exception during installation"""
191191

192192

193+
class FailedToPrepareCandidate(InstallationError):
194+
"""Raised when we fail to prepare a candidate (i.e. fetch and generate metadata).
195+
196+
This is intentionally not a diagnostic error, since the output will be presented
197+
above this error, when this occurs. This should instead present information to the
198+
user.
199+
"""
200+
201+
def __init__(
202+
self, *, package_name: str, requirement_chain: str, failed_step: str
203+
) -> None:
204+
super().__init__(f"Failed to build '{package_name}' when {failed_step.lower()}")
205+
self.package_name = package_name
206+
self.requirement_chain = requirement_chain
207+
self.failed_step = failed_step
208+
209+
193210
class MissingPyProjectBuildRequires(DiagnosticPipError):
194211
"""Raised when pyproject.toml has `build-system`, but no `build-system.requires`."""
195212

@@ -384,7 +401,7 @@ def __init__(
384401
output_lines: list[str] | None,
385402
) -> None:
386403
if output_lines is None:
387-
output_prompt = Text("See above for output.")
404+
output_prompt = Text("No available output.")
388405
else:
389406
output_prompt = (
390407
Text.from_markup(f"[red][{len(output_lines)} lines of output][/]\n")
@@ -412,15 +429,15 @@ def __str__(self) -> str:
412429
return f"{self.command_description} exited with {self.exit_code}"
413430

414431

415-
class MetadataGenerationFailed(InstallationSubprocessError, InstallationError):
432+
class MetadataGenerationFailed(DiagnosticPipError, InstallationError):
416433
reference = "metadata-generation-failed"
417434

418435
def __init__(
419436
self,
420437
*,
421438
package_details: str,
422439
) -> None:
423-
super(InstallationSubprocessError, self).__init__(
440+
super().__init__(
424441
message="Encountered error while generating package metadata.",
425442
context=escape(package_details),
426443
hint_stmt="See above for details.",

src/pip/_internal/resolution/resolvelib/candidates.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pip._vendor.packaging.version import Version
1111

1212
from pip._internal.exceptions import (
13+
FailedToPrepareCandidate,
1314
HashError,
1415
InstallationSubprocessError,
1516
InvalidInstalledPackage,
@@ -244,9 +245,19 @@ def _prepare(self) -> BaseDistribution:
244245
e.req = self._ireq
245246
raise
246247
except InstallationSubprocessError as exc:
247-
# The output has been presented already, so don't duplicate it.
248-
exc.context = "See above for output."
249-
raise
248+
if isinstance(self._ireq.comes_from, InstallRequirement):
249+
request_chain = self._ireq.comes_from.from_path()
250+
else:
251+
request_chain = self._ireq.comes_from
252+
253+
if request_chain is None:
254+
request_chain = "directly requested"
255+
256+
raise FailedToPrepareCandidate(
257+
package_name=self._ireq.name or str(self._link),
258+
requirement_chain=request_chain,
259+
failed_step=exc.command_description,
260+
)
250261

251262
self._check_metadata_consistency(dist)
252263
return dist

src/pip/_internal/utils/unpacking.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,30 @@ def pip_filter(member: tarfile.TarInfo, path: str) -> tarfile.TarInfo:
248248
tar.close()
249249

250250

251+
def is_symlink_target_in_tar(tar: tarfile.TarFile, tarinfo: tarfile.TarInfo) -> bool:
252+
"""Check if the file pointed to by the symbolic link is in the tar archive"""
253+
linkname = os.path.join(os.path.dirname(tarinfo.name), tarinfo.linkname)
254+
255+
linkname = os.path.normpath(linkname)
256+
linkname = linkname.replace("\\", "/")
257+
258+
try:
259+
tar.getmember(linkname)
260+
return True
261+
except KeyError:
262+
return False
263+
264+
251265
def _untar_without_filter(
252266
filename: str,
253267
location: str,
254268
tar: tarfile.TarFile,
255269
leading: bool,
256270
) -> None:
257271
"""Fallback for Python without tarfile.data_filter"""
272+
# NOTE: This function can be removed once pip requires CPython ≥ 3.12.​
273+
# PEP 706 added tarfile.data_filter, made tarfile extraction operations more secure.
274+
# This feature is fully supported from CPython 3.12 onward.
258275
for member in tar.getmembers():
259276
fn = member.name
260277
if leading:
@@ -269,6 +286,14 @@ def _untar_without_filter(
269286
if member.isdir():
270287
ensure_dir(path)
271288
elif member.issym():
289+
if not is_symlink_target_in_tar(tar, member):
290+
message = (
291+
"The tar file ({}) has a file ({}) trying to install "
292+
"outside target directory ({})"
293+
)
294+
raise InstallationError(
295+
message.format(filename, member.name, member.linkname)
296+
)
272297
try:
273298
tar._extract_member(member, path)
274299
except Exception as exc:

tests/lib/venv.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ def _create(self, clear: bool = False) -> None:
113113
_virtualenv.cli_run(
114114
[
115115
"--no-pip",
116-
"--no-wheel",
117116
"--no-setuptools",
118117
os.fspath(self.location),
119118
],

tests/unit/test_utils_unpacking.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pathlib import Path
1111

1212
import pytest
13+
from _pytest.monkeypatch import MonkeyPatch
1314

1415
from pip._internal.exceptions import InstallationError
1516
from pip._internal.utils.unpacking import is_within_directory, untar_file, unzip_file
@@ -238,6 +239,148 @@ def test_unpack_tar_links(self, input_prefix: str, unpack_prefix: str) -> None:
238239
with open(os.path.join(unpack_dir, "symlink.txt"), "rb") as f:
239240
assert f.read() == content
240241

242+
def test_unpack_normal_tar_link1_no_data_filter(
243+
self, monkeypatch: MonkeyPatch
244+
) -> None:
245+
"""
246+
Test unpacking a normal tar with file containing soft links, but no data_filter
247+
"""
248+
if hasattr(tarfile, "data_filter"):
249+
monkeypatch.delattr("tarfile.data_filter")
250+
251+
tar_filename = "test_tar_links_no_data_filter.tar"
252+
tar_filepath = os.path.join(self.tempdir, tar_filename)
253+
254+
extract_path = os.path.join(self.tempdir, "extract_path")
255+
256+
with tarfile.open(tar_filepath, "w") as tar:
257+
file_data = io.BytesIO(b"normal\n")
258+
normal_file_tarinfo = tarfile.TarInfo(name="normal_file")
259+
normal_file_tarinfo.size = len(file_data.getbuffer())
260+
tar.addfile(normal_file_tarinfo, fileobj=file_data)
261+
262+
info = tarfile.TarInfo("normal_symlink")
263+
info.type = tarfile.SYMTYPE
264+
info.linkpath = "normal_file"
265+
tar.addfile(info)
266+
267+
untar_file(tar_filepath, extract_path)
268+
269+
assert os.path.islink(os.path.join(extract_path, "normal_symlink"))
270+
271+
link_path = os.readlink(os.path.join(extract_path, "normal_symlink"))
272+
assert link_path == "normal_file"
273+
274+
with open(os.path.join(extract_path, "normal_symlink"), "rb") as f:
275+
assert f.read() == b"normal\n"
276+
277+
def test_unpack_normal_tar_link2_no_data_filter(
278+
self, monkeypatch: MonkeyPatch
279+
) -> None:
280+
"""
281+
Test unpacking a normal tar with file containing soft links, but no data_filter
282+
"""
283+
if hasattr(tarfile, "data_filter"):
284+
monkeypatch.delattr("tarfile.data_filter")
285+
286+
tar_filename = "test_tar_links_no_data_filter.tar"
287+
tar_filepath = os.path.join(self.tempdir, tar_filename)
288+
289+
extract_path = os.path.join(self.tempdir, "extract_path")
290+
291+
with tarfile.open(tar_filepath, "w") as tar:
292+
file_data = io.BytesIO(b"normal\n")
293+
normal_file_tarinfo = tarfile.TarInfo(name="normal_file")
294+
normal_file_tarinfo.size = len(file_data.getbuffer())
295+
tar.addfile(normal_file_tarinfo, fileobj=file_data)
296+
297+
info = tarfile.TarInfo("sub/normal_symlink")
298+
info.type = tarfile.SYMTYPE
299+
info.linkpath = ".." + os.path.sep + "normal_file"
300+
tar.addfile(info)
301+
302+
untar_file(tar_filepath, extract_path)
303+
304+
assert os.path.islink(os.path.join(extract_path, "sub", "normal_symlink"))
305+
306+
link_path = os.readlink(os.path.join(extract_path, "sub", "normal_symlink"))
307+
assert link_path == ".." + os.path.sep + "normal_file"
308+
309+
with open(os.path.join(extract_path, "sub", "normal_symlink"), "rb") as f:
310+
assert f.read() == b"normal\n"
311+
312+
def test_unpack_evil_tar_link1_no_data_filter(
313+
self, monkeypatch: MonkeyPatch
314+
) -> None:
315+
"""
316+
Test unpacking a evil tar with file containing soft links, but no data_filter
317+
"""
318+
if hasattr(tarfile, "data_filter"):
319+
monkeypatch.delattr("tarfile.data_filter")
320+
321+
tar_filename = "test_tar_links_no_data_filter.tar"
322+
tar_filepath = os.path.join(self.tempdir, tar_filename)
323+
324+
import_filename = "import_file"
325+
import_filepath = os.path.join(self.tempdir, import_filename)
326+
open(import_filepath, "w").close()
327+
328+
extract_path = os.path.join(self.tempdir, "extract_path")
329+
330+
with tarfile.open(tar_filepath, "w") as tar:
331+
info = tarfile.TarInfo("evil_symlink")
332+
info.type = tarfile.SYMTYPE
333+
info.linkpath = import_filepath
334+
tar.addfile(info)
335+
336+
with pytest.raises(InstallationError) as e:
337+
untar_file(tar_filepath, extract_path)
338+
339+
msg = (
340+
"The tar file ({}) has a file ({}) trying to install outside "
341+
"target directory ({})"
342+
)
343+
assert msg.format(tar_filepath, "evil_symlink", import_filepath) in str(e.value)
344+
345+
assert not os.path.exists(os.path.join(extract_path, "evil_symlink"))
346+
347+
def test_unpack_evil_tar_link2_no_data_filter(
348+
self, monkeypatch: MonkeyPatch
349+
) -> None:
350+
"""
351+
Test unpacking a evil tar with file containing soft links, but no data_filter
352+
"""
353+
if hasattr(tarfile, "data_filter"):
354+
monkeypatch.delattr("tarfile.data_filter")
355+
356+
tar_filename = "test_tar_links_no_data_filter.tar"
357+
tar_filepath = os.path.join(self.tempdir, tar_filename)
358+
359+
import_filename = "import_file"
360+
import_filepath = os.path.join(self.tempdir, import_filename)
361+
open(import_filepath, "w").close()
362+
363+
extract_path = os.path.join(self.tempdir, "extract_path")
364+
365+
link_path = ".." + os.sep + import_filename
366+
367+
with tarfile.open(tar_filepath, "w") as tar:
368+
info = tarfile.TarInfo("evil_symlink")
369+
info.type = tarfile.SYMTYPE
370+
info.linkpath = link_path
371+
tar.addfile(info)
372+
373+
with pytest.raises(InstallationError) as e:
374+
untar_file(tar_filepath, extract_path)
375+
376+
msg = (
377+
"The tar file ({}) has a file ({}) trying to install outside "
378+
"target directory ({})"
379+
)
380+
assert msg.format(tar_filepath, "evil_symlink", link_path) in str(e.value)
381+
382+
assert not os.path.exists(os.path.join(extract_path, "evil_symlink"))
383+
241384

242385
def test_unpack_tar_unicode(tmpdir: Path) -> None:
243386
test_tar = tmpdir / "test.tar"

0 commit comments

Comments
 (0)