mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-11-13 03:37:15 +00:00
python3-virtualenv: fix CVE-2026-102930
Backport the seed-wheel digest check [1], as identified by advisory [5]. Include prerequisite [2] so the expected digests are obtained over verified HTTPS, and follow-up [3] so missing digests cannot silently disable verification. Include follow-up [4] to verify wheels in private staging before caching them and to use the same verification for the pip seeder. This also fixes CVE-2026-113714 and CVE-2026-113717, tracked in [6] and [7]. Apply the TLS prerequisite before the three wheel fixes. Add python3-packaging for the upstream wheel-name and version checks. Keep virtualenv at version 21.2.1 without unrelated feature changes. [1] https://github.com/pypa/virtualenv/commit/a01ed3e2f239d6ab1fce62c5c7664ccf268fff6d [2] https://github.com/pypa/virtualenv/commit/48f6fdcc14b8654236f9081adbff10a32f536940 [3] https://github.com/pypa/virtualenv/commit/91460add8f53f4654c4aae3898e739c03fb69946 [4] https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96 [5] https://github.com/pypa/virtualenv/security/advisories/GHSA-94p9-xgh2-xp45 [6] https://github.com/pypa/virtualenv/security/advisories/GHSA-494p-63j8-28p3 [7] https://github.com/pypa/virtualenv/security/advisories/GHSA-cx28-75q4-x26q Signed-off-by: Hetvi Thakar <hthakar@cisco.com> Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
1 parent
a0db4b52e9
commit
2f89f21927
5 files changed
+1499
No files matched your search
+115
@@ -0,0 +1,115 @@
|
||||
From 48f6fdcc14b8654236f9081adbff10a32f536940 Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
Date: Tue, 14 Apr 2026 13:20:04 -0700
|
||||
Subject: [PATCH] fix(periodic-update): refuse unverified HTTPS to PyPI by
|
||||
default (#3122)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
Security hardening. When the verified HTTPS request to
|
||||
`https://pypi.org/pypi/<dist>/json` failed, the periodic update retried
|
||||
the request with an unverified SSL context and used whatever came back.
|
||||
The original comment justified this with 'the information we request is
|
||||
not sensitive', but the metadata drives the decision about which wheel
|
||||
version virtualenv considers 'up to date'. 🔒 A network-level attacker
|
||||
that can break the handshake can then pin virtualenv to an older seed
|
||||
wheel and silently suppress security updates.
|
||||
|
||||
The fallback is now skipped by default. A failed verified request logs
|
||||
the error and returns `None`, which the periodic update path already
|
||||
handles gracefully — no wheel bump happens and the run continues. Hosts
|
||||
with genuinely broken trust stores can opt back in by setting
|
||||
`VIRTUALENV_PERIODIC_UPDATE_INSECURE=1`, which is logged at WARNING so
|
||||
it shows up in CI output. The env var is documented in
|
||||
`docs/how-to/usage.rst` next to the existing override knobs.
|
||||
|
||||
CVE: CVE-2026-102930
|
||||
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/48f6fdcc14b8654236f9081adbff10a32f536940]
|
||||
|
||||
Backport Changes:
|
||||
- Remove the automatic unverified-HTTPS retry required to trust the
|
||||
PyPI digests used by the following security fixes.
|
||||
- Omit the new insecure opt-in environment variable and its usage
|
||||
documentation. Preserve strict TLS verification without introducing
|
||||
another compatibility option or its boolean-parsing follow-up.
|
||||
- Adapt the existing release-lookup regression to reject an unverified
|
||||
retry, and update its cached lookup-count expectations accordingly.
|
||||
- Omit the upstream release changelog fragment.
|
||||
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
src/virtualenv/seed/wheels/periodic_update.py | 2 --
|
||||
tests/unit/seed/wheels/test_periodic_update.py | 16 ++++++++++------
|
||||
2 files changed, 10 insertions(+), 8 deletions(-)
|
||||
|
||||
diff --git a/src/virtualenv/seed/wheels/periodic_update.py b/src/virtualenv/seed/wheels/periodic_update.py
|
||||
index 57193ee..67a0337 100644
|
||||
--- a/src/virtualenv/seed/wheels/periodic_update.py
|
||||
+++ b/src/virtualenv/seed/wheels/periodic_update.py
|
||||
@@ -362,8 +362,6 @@ def release_date_for_wheel_path(dest: Path) -> datetime | None:
|
||||
|
||||
def _request_context() -> Generator[ssl.SSLContext | None, None, None]:
|
||||
yield None
|
||||
- # fallback to non verified HTTPS (the information we request is not sensitive, so fallback)
|
||||
- yield ssl._create_unverified_context() # noqa: S323, SLF001
|
||||
|
||||
|
||||
_PYPI_CACHE = {}
|
||||
diff --git a/tests/unit/seed/wheels/test_periodic_update.py b/tests/unit/seed/wheels/test_periodic_update.py
|
||||
index e683d6c..3fabf75 100644
|
||||
--- a/tests/unit/seed/wheels/test_periodic_update.py
|
||||
+++ b/tests/unit/seed/wheels/test_periodic_update.py
|
||||
@@ -501,7 +501,11 @@ def test_new_version_ne() -> None:
|
||||
)
|
||||
|
||||
|
||||
-def test_get_release_unsecure(mocker, caplog) -> None:
|
||||
+def test_get_release_does_not_retry_unverified(mocker, caplog) -> None:
|
||||
+ mocker.patch(
|
||||
+ "virtualenv.seed.wheels.periodic_update.ssl._create_unverified_context",
|
||||
+ side_effect=AssertionError("must not disable TLS verification"),
|
||||
+ )
|
||||
@contextmanager
|
||||
def _release(of, context):
|
||||
assert of == "https://pypi.org/pypi/pip/json"
|
||||
@@ -515,8 +519,8 @@ def test_get_release_unsecure(mocker, caplog) -> None:
|
||||
|
||||
result = release_date_for_wheel_path(Path("pip-20.1.whl"))
|
||||
|
||||
- assert result == datetime(year=2020, month=12, day=22, hour=12, minute=12, second=12, tzinfo=timezone.utc)
|
||||
- assert url_o.call_count == 2
|
||||
+ assert result is None
|
||||
+ assert url_o.call_count == 1
|
||||
assert "insecure" in caplog.text
|
||||
assert " failed " in caplog.text
|
||||
|
||||
@@ -564,7 +568,7 @@ def test_download_stop_with_embed(tmp_path, mocker, time_freeze) -> None:
|
||||
do_update("pip", "3.9", str(wheel.path), str(app_data_outer), [], True)
|
||||
|
||||
assert download_wheel.call_count == 3
|
||||
- assert url_o.call_count == 2
|
||||
+ assert url_o.call_count == 1
|
||||
|
||||
assert read_dict.call_count == 1
|
||||
assert write.call_count == 1
|
||||
@@ -587,7 +591,7 @@ def test_download_manual_stop_after_one_download(tmp_path, mocker, time_freeze)
|
||||
do_update("pip", "3.9", str(wheel.path), str(app_data_outer), [], False)
|
||||
|
||||
assert download_wheel.call_count == 1
|
||||
- assert url_o.call_count == 2
|
||||
+ assert url_o.call_count == 1
|
||||
assert read_dict.call_count == 1
|
||||
assert write.call_count == 1
|
||||
|
||||
@@ -610,7 +614,7 @@ def test_download_manual_ignores_pre_release(tmp_path, mocker, time_freeze) -> N
|
||||
do_update("pip", "3.9", str(wheel.path), str(app_data_outer), [], False)
|
||||
|
||||
assert download_wheel.call_count == 1
|
||||
- assert url_o.call_count == 2
|
||||
+ assert url_o.call_count == 1
|
||||
assert read_dict.call_count == 1
|
||||
assert write.call_count == 1
|
||||
wrote_json = write.call_args[0][0]
|
||||
--
|
||||
2.35.6
|
||||
@@ -0,0 +1,309 @@
|
||||
From a01ed3e2f239d6ab1fce62c5c7664ccf268fff6d Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
Date: Fri, 18 Sep 2026 03:37:42 +0200
|
||||
Subject: [PATCH] fix(seed): verify downloaded wheel digests (#3251)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
|
||||
|
||||
CVE: CVE-2026-102930
|
||||
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/a01ed3e2f239d6ab1fce62c5c7664ccf268fff6d]
|
||||
|
||||
Backport Changes:
|
||||
- Omit the upstream release changelog fragment and additions to the
|
||||
downloader docstring, which describes validation APIs not in 21.2.1.
|
||||
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
src/virtualenv/seed/wheels/acquire.py | 27 ++++++++--
|
||||
src/virtualenv/seed/wheels/periodic_update.py | 38 ++++++++++++++
|
||||
tests/unit/seed/wheels/test_acquire.py | 52 +++++++++++++++++++
|
||||
.../unit/seed/wheels/test_periodic_update.py | 52 +++++++++++++++++++
|
||||
4 files changed, 165 insertions(+), 4 deletions(-)
|
||||
|
||||
diff --git a/src/virtualenv/seed/wheels/acquire.py b/src/virtualenv/seed/wheels/acquire.py
|
||||
index b6a6533..58d7af6 100644
|
||||
--- a/src/virtualenv/seed/wheels/acquire.py
|
||||
+++ b/src/virtualenv/seed/wheels/acquire.py
|
||||
@@ -7,10 +7,10 @@ import sys
|
||||
from operator import eq, lt
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, CalledProcessError, Popen
|
||||
-from typing import TYPE_CHECKING
|
||||
+from typing import TYPE_CHECKING, Final
|
||||
|
||||
from .bundle import from_bundle
|
||||
-from .periodic_update import add_wheel_to_update_log
|
||||
+from .periodic_update import add_wheel_to_update_log, verify_wheel_digest
|
||||
from .util import Version, Wheel, discover_wheels
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -18,6 +18,19 @@ if TYPE_CHECKING:
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
+# A private index can legitimately serve a rebuilt wheel under the same distribution, version, and
|
||||
+# filename PyPI also uses - an internal mirror re-signing or re-packaging a release for policy reasons,
|
||||
+# for example. Verifying that file against PyPI's own digest would then reject a wheel that came from
|
||||
+# exactly the source it was configured to come from, not a compromised one. This does not attempt to
|
||||
+# find every way pip can be pointed elsewhere (a pip.conf file has the same effect and is invisible
|
||||
+# here), only the common, explicit one, so treat this as a conservative signal rather than proof either
|
||||
+# way: it exists to avoid false rejections, not to guarantee verification runs whenever it safely could.
|
||||
+_CUSTOM_INDEX_ENV_VARS: Final[tuple[str, ...]] = ("PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_INDEX")
|
||||
+
|
||||
+
|
||||
+def _uses_default_index(env: dict[str, str]) -> bool:
|
||||
+ return not any(env.get(var) for var in _CUSTOM_INDEX_ENV_VARS)
|
||||
+
|
||||
|
||||
def get_wheel( # noqa: PLR0913
|
||||
distribution: str,
|
||||
@@ -81,6 +94,7 @@ def download_wheel( # noqa: PLR0913
|
||||
str(to_folder),
|
||||
to_download,
|
||||
]
|
||||
+ verify_against_pypi = _uses_default_index(env)
|
||||
# pip has no interface in python - must be a new sub-process
|
||||
env = pip_wheel_env_run(search_dirs, app_data, env)
|
||||
process = Popen(cmd, env=env, stdout=PIPE, stderr=PIPE, universal_newlines=True, encoding="utf-8")
|
||||
@@ -89,8 +103,13 @@ def download_wheel( # noqa: PLR0913
|
||||
kwargs = {"output": out, "stderr": err}
|
||||
raise CalledProcessError(process.returncode, cmd, **kwargs)
|
||||
result = _find_downloaded_wheel(distribution, version_spec, for_py_version, to_folder, out)
|
||||
- LOGGER.debug("downloaded wheel %s", result.name) # ty: ignore[unresolved-attribute]
|
||||
- return result # ty: ignore[invalid-return-type]
|
||||
+ if result is None:
|
||||
+ msg = f"could not find downloaded wheel for {to_download}"
|
||||
+ raise RuntimeError(msg)
|
||||
+ LOGGER.debug("downloaded wheel %s", result.name)
|
||||
+ if verify_against_pypi:
|
||||
+ verify_wheel_digest(result)
|
||||
+ return result
|
||||
|
||||
|
||||
def _find_downloaded_wheel(
|
||||
diff --git a/src/virtualenv/seed/wheels/periodic_update.py b/src/virtualenv/seed/wheels/periodic_update.py
|
||||
index 67a0337..f911882 100644
|
||||
--- a/src/virtualenv/seed/wheels/periodic_update.py
|
||||
+++ b/src/virtualenv/seed/wheels/periodic_update.py
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
+import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -360,6 +361,42 @@ def release_date_for_wheel_path(dest: Path) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
+def verify_wheel_digest(wheel: Wheel) -> None:
|
||||
+ """Verify a downloaded wheel's sha256 against the digest PyPI's JSON API reports for that release.
|
||||
+
|
||||
+ ``pip download`` trusts whatever index it is configured to use, and nothing else in this module checks the bytes it
|
||||
+ hands back. A missing PyPI record only means this check cannot run - the wheel may come from a private index PyPI
|
||||
+ has never heard of - so that case is logged and let through. An actual mismatch means the file on disk is not the
|
||||
+ release PyPI published under that filename, which is what a compromised index, a stale mirror, or a MITM'd download
|
||||
+ would produce, so that case is fatal: the wheel must never be cached or seeded into a venv.
|
||||
+
|
||||
+ :raises RuntimeError: if PyPI's record for this exact filename exists and the digest does not match.
|
||||
+
|
||||
+ """
|
||||
+ entry = _pypi_release_entry_for_wheel(wheel)
|
||||
+ if entry is None:
|
||||
+ LOGGER.debug("could not verify %s against PyPI: no matching release record", wheel.name)
|
||||
+ return
|
||||
+ digests = entry.get("digests")
|
||||
+ expected = digests.get("sha256") if isinstance(digests, dict) else None
|
||||
+ if not isinstance(expected, str):
|
||||
+ LOGGER.debug("could not verify %s against PyPI: no sha256 digest published", wheel.name)
|
||||
+ return
|
||||
+ actual = hashlib.sha256(wheel.path.read_bytes()).hexdigest()
|
||||
+ if actual != expected:
|
||||
+ msg = f"downloaded wheel {wheel.name} has sha256 {actual}, but PyPI reports {expected} for this release"
|
||||
+ raise RuntimeError(msg)
|
||||
+
|
||||
+
|
||||
+def _pypi_release_entry_for_wheel(wheel: Wheel) -> dict[str, object] | None:
|
||||
+ content = _pypi_get_distribution_info_cached(wheel.distribution)
|
||||
+ releases = content.get("releases") if isinstance(content, dict) else None
|
||||
+ entries = releases.get(wheel.version) if isinstance(releases, dict) else None
|
||||
+ if not isinstance(entries, list):
|
||||
+ return None
|
||||
+ return next((entry for entry in entries if isinstance(entry, dict) and entry.get("filename") == wheel.name), None)
|
||||
+
|
||||
+
|
||||
def _request_context() -> Generator[ssl.SSLContext | None, None, None]:
|
||||
yield None
|
||||
|
||||
@@ -453,4 +490,5 @@ __all__ = [
|
||||
"periodic_update",
|
||||
"release_date_for_wheel_path",
|
||||
"trigger_update",
|
||||
+ "verify_wheel_digest",
|
||||
]
|
||||
diff --git a/tests/unit/seed/wheels/test_acquire.py b/tests/unit/seed/wheels/test_acquire.py
|
||||
index 7754a8c..b9f7b5b 100644
|
||||
--- a/tests/unit/seed/wheels/test_acquire.py
|
||||
+++ b/tests/unit/seed/wheels/test_acquire.py
|
||||
@@ -38,6 +38,7 @@ def test_download_wheel_bad_output(mocker, for_py_version, session_app_data) ->
|
||||
distribution = "setuptools"
|
||||
p_open = mocker.MagicMock()
|
||||
mocker.patch("virtualenv.seed.wheels.acquire.Popen", return_value=p_open)
|
||||
+ mocker.patch("virtualenv.seed.wheels.acquire.verify_wheel_digest")
|
||||
p_open.communicate.return_value = "", ""
|
||||
p_open.returncode = 0
|
||||
|
||||
@@ -58,6 +59,19 @@ def test_download_wheel_bad_output(mocker, for_py_version, session_app_data) ->
|
||||
assert result.path == embed.path
|
||||
|
||||
|
||||
+def test_download_wheel_not_found_raises(mocker, for_py_version, session_app_data) -> None:
|
||||
+ """download_wheel must raise a clear error rather than crash when no wheel can be identified at all."""
|
||||
+ p_open = mocker.MagicMock()
|
||||
+ mocker.patch("virtualenv.seed.wheels.acquire.Popen", return_value=p_open)
|
||||
+ p_open.communicate.return_value = "", ""
|
||||
+ p_open.returncode = 0
|
||||
+ as_path = mocker.MagicMock()
|
||||
+ as_path.iterdir.return_value = []
|
||||
+
|
||||
+ with pytest.raises(RuntimeError, match="could not find downloaded wheel"):
|
||||
+ download_wheel("setuptools", None, for_py_version, [], session_app_data, as_path, os.environ)
|
||||
+
|
||||
+
|
||||
def test_download_fails(mocker, for_py_version, session_app_data) -> None:
|
||||
p_open = mocker.MagicMock()
|
||||
mocker.patch("virtualenv.seed.wheels.acquire.Popen", return_value=p_open)
|
||||
@@ -94,6 +108,7 @@ def test_download_wheel_python_io_encoding(mocker, for_py_version, session_app_d
|
||||
mock_popen.return_value.communicate.return_value = "Saved a-b-c.whl", ""
|
||||
mock_popen.return_value.returncode = 0
|
||||
mocker.patch("pathlib.Path.absolute", return_value=Path("a-b-c.whl"))
|
||||
+ mocker.patch("virtualenv.seed.wheels.acquire.verify_wheel_digest")
|
||||
|
||||
download_wheel("pip", "==1", for_py_version, [], session_app_data, "folder", os.environ.copy())
|
||||
|
||||
@@ -101,6 +116,43 @@ def test_download_wheel_python_io_encoding(mocker, for_py_version, session_app_d
|
||||
assert env["PYTHONIOENCODING"] == "utf-8"
|
||||
|
||||
|
||||
+def test_download_wheel_verifies_digest(mocker, for_py_version, session_app_data) -> None:
|
||||
+ """download_wheel must reject a wheel whose bytes do not match PyPI's published digest."""
|
||||
+ mock_popen = mocker.patch("virtualenv.seed.wheels.acquire.Popen")
|
||||
+ mock_popen.return_value.communicate.return_value = "Saved a-b-c.whl", ""
|
||||
+ mock_popen.return_value.returncode = 0
|
||||
+ mocker.patch("pathlib.Path.absolute", return_value=Path("a-b-c.whl"))
|
||||
+ verify = mocker.patch(
|
||||
+ "virtualenv.seed.wheels.acquire.verify_wheel_digest",
|
||||
+ side_effect=RuntimeError("digest mismatch"),
|
||||
+ )
|
||||
+
|
||||
+ with pytest.raises(RuntimeError, match="digest mismatch"):
|
||||
+ download_wheel("pip", "==1", for_py_version, [], session_app_data, "folder", os.environ.copy())
|
||||
+
|
||||
+ assert verify.call_count == 1
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize("env_var", ["PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_INDEX"])
|
||||
+def test_download_wheel_skips_digest_check_for_custom_index(mocker, for_py_version, session_app_data, env_var) -> None:
|
||||
+ """A configured custom index may legitimately serve a same-named wheel PyPI never built."""
|
||||
+ mock_popen = mocker.patch("virtualenv.seed.wheels.acquire.Popen")
|
||||
+ mock_popen.return_value.communicate.return_value = "Saved a-b-c.whl", ""
|
||||
+ mock_popen.return_value.returncode = 0
|
||||
+ mocker.patch("pathlib.Path.absolute", return_value=Path("a-b-c.whl"))
|
||||
+ verify = mocker.patch(
|
||||
+ "virtualenv.seed.wheels.acquire.verify_wheel_digest",
|
||||
+ side_effect=RuntimeError("would have rejected a legitimate wheel"),
|
||||
+ )
|
||||
+ env = os.environ.copy()
|
||||
+ env[env_var] = "https://example.com/simple"
|
||||
+
|
||||
+ result = download_wheel("pip", "==1", for_py_version, [], session_app_data, "folder", env)
|
||||
+
|
||||
+ assert result.name == "a-b-c.whl"
|
||||
+ assert verify.call_count == 0
|
||||
+
|
||||
+
|
||||
@pytest.fixture
|
||||
def downloaded_wheel(mocker):
|
||||
wheel = Wheel.from_path(Path("setuptools-0.0.0-py2.py3-none-any.whl"))
|
||||
diff --git a/tests/unit/seed/wheels/test_periodic_update.py b/tests/unit/seed/wheels/test_periodic_update.py
|
||||
index 3fabf75..6a54455 100644
|
||||
--- a/tests/unit/seed/wheels/test_periodic_update.py
|
||||
+++ b/tests/unit/seed/wheels/test_periodic_update.py
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
+import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -29,6 +30,7 @@ from virtualenv.seed.wheels.periodic_update import (
|
||||
periodic_update,
|
||||
release_date_for_wheel_path,
|
||||
trigger_update,
|
||||
+ verify_wheel_digest,
|
||||
)
|
||||
from virtualenv.util.subprocess import CREATE_NO_WINDOW
|
||||
|
||||
@@ -536,6 +538,56 @@ def test_get_release_fails(mocker, caplog) -> None:
|
||||
assert repr(exc) in caplog.text
|
||||
|
||||
|
||||
+def _pypi_release_response(mocker, filename, digests, version="20.1"):
|
||||
+ entry = {"filename": filename, "digests": digests}
|
||||
+ body = json.dumps({"releases": {version: [entry]}})
|
||||
+ return mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", return_value=StringIO(body))
|
||||
+
|
||||
+
|
||||
+def test_verify_wheel_digest_matches(tmp_path, mocker) -> None:
|
||||
+ wheel = Wheel(tmp_path / "pip-20.1-py3-none-any.whl")
|
||||
+ wheel.path.write_bytes(b"content")
|
||||
+ sha256 = hashlib.sha256(b"content").hexdigest()
|
||||
+ _pypi_release_response(mocker, wheel.name, {"sha256": sha256})
|
||||
+
|
||||
+ verify_wheel_digest(wheel) # must not raise
|
||||
+
|
||||
+
|
||||
+def test_verify_wheel_digest_mismatch(tmp_path, mocker) -> None:
|
||||
+ wheel = Wheel(tmp_path / "pip-20.1-py3-none-any.whl")
|
||||
+ wheel.path.write_bytes(b"content")
|
||||
+ _pypi_release_response(mocker, wheel.name, {"sha256": "0" * 64})
|
||||
+
|
||||
+ with pytest.raises(RuntimeError, match=r"has sha256 .* but PyPI reports"):
|
||||
+ verify_wheel_digest(wheel)
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize(
|
||||
+ ("filename", "digests", "version"),
|
||||
+ [
|
||||
+ pytest.param("setuptools-99.0-py3-none-any.whl", {"sha256": "0" * 64}, "20.1", id="filename_not_in_release"),
|
||||
+ pytest.param(
|
||||
+ "pip-20.1-py3-none-any.whl", {"md5": "d41d8cd98f00b204e9800998ecf8427e"}, "20.1", id="no_sha256_digest"
|
||||
+ ),
|
||||
+ pytest.param("pip-20.1-py3-none-any.whl", {"sha256": "0" * 64}, "19.0", id="version_not_in_releases"),
|
||||
+ ],
|
||||
+)
|
||||
+def test_verify_wheel_digest_skips_when_unverifiable(tmp_path, mocker, filename, digests, version) -> None:
|
||||
+ wheel = Wheel(tmp_path / "pip-20.1-py3-none-any.whl")
|
||||
+ wheel.path.write_bytes(b"content")
|
||||
+ _pypi_release_response(mocker, filename, digests, version)
|
||||
+
|
||||
+ verify_wheel_digest(wheel) # must not raise: nothing to compare against
|
||||
+
|
||||
+
|
||||
+def test_verify_wheel_digest_skips_on_pypi_lookup_failure(tmp_path, mocker) -> None:
|
||||
+ wheel = Wheel(tmp_path / "pip-20.1-py3-none-any.whl")
|
||||
+ wheel.path.write_bytes(b"content")
|
||||
+ mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", side_effect=URLError("offline"))
|
||||
+
|
||||
+ verify_wheel_digest(wheel) # must not raise: a private mirror PyPI never heard of is legitimate
|
||||
+
|
||||
+
|
||||
def mock_download(mocker, pip_version_remote):
|
||||
def download():
|
||||
index = 0
|
||||
--
|
||||
2.35.6
|
||||
@@ -0,0 +1,293 @@
|
||||
From 91460add8f53f4654c4aae3898e739c03fb69946 Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
Date: Tue, 22 Sep 2026 17:10:31 -0700
|
||||
Subject: [PATCH] fix(seed): fail closed when PyPI digest is unknown (#3302)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
CVE: CVE-2026-102930
|
||||
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/91460add8f53f4654c4aae3898e739c03fb69946]
|
||||
|
||||
Backport Changes:
|
||||
- Omit the upstream release changelog fragment and its edit to the
|
||||
newer downloader docstring, which is absent from 21.2.1.
|
||||
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
src/virtualenv/seed/wheels/acquire.py | 27 ++++++----
|
||||
src/virtualenv/seed/wheels/periodic_update.py | 53 ++++++++++++-------
|
||||
tests/unit/seed/wheels/test_acquire.py | 22 +++++++-
|
||||
.../unit/seed/wheels/test_periodic_update.py | 42 +++++++++++++--
|
||||
4 files changed, 109 insertions(+), 35 deletions(-)
|
||||
|
||||
diff --git a/src/virtualenv/seed/wheels/acquire.py b/src/virtualenv/seed/wheels/acquire.py
|
||||
index 58d7af6..9127db1 100644
|
||||
--- a/src/virtualenv/seed/wheels/acquire.py
|
||||
+++ b/src/virtualenv/seed/wheels/acquire.py
|
||||
@@ -10,7 +10,7 @@ from subprocess import PIPE, CalledProcessError, Popen
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from .bundle import from_bundle
|
||||
-from .periodic_update import add_wheel_to_update_log, verify_wheel_digest
|
||||
+from .periodic_update import UnverifiedWheelError, add_wheel_to_update_log, verify_wheel_digest
|
||||
from .util import Version, Wheel, discover_wheels
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -52,15 +52,22 @@ def get_wheel( # noqa: PLR0913
|
||||
|
||||
if download and wheel is None and version != Version.embed:
|
||||
# 2. download from the internet
|
||||
- wheel = download_wheel(
|
||||
- distribution=distribution,
|
||||
- version_spec=Version.as_version_spec(version),
|
||||
- for_py_version=for_py_version,
|
||||
- search_dirs=search_dirs,
|
||||
- app_data=app_data,
|
||||
- to_folder=app_data.house,
|
||||
- env=env,
|
||||
- )
|
||||
+ try:
|
||||
+ wheel = download_wheel(
|
||||
+ distribution=distribution,
|
||||
+ version_spec=Version.as_version_spec(version),
|
||||
+ for_py_version=for_py_version,
|
||||
+ search_dirs=search_dirs,
|
||||
+ app_data=app_data,
|
||||
+ to_folder=app_data.house,
|
||||
+ env=env,
|
||||
+ )
|
||||
+ except UnverifiedWheelError as exception:
|
||||
+ # only the bundle request has a local wheel that satisfies it; a pinned version must fail
|
||||
+ if version != Version.bundle:
|
||||
+ raise
|
||||
+ LOGGER.warning("%s, seeding the bundled %s instead", exception, distribution)
|
||||
+ return from_bundle(distribution, version, for_py_version, search_dirs, app_data, do_periodic_update, env)
|
||||
if wheel is not None and app_data.can_update:
|
||||
add_wheel_to_update_log(wheel, for_py_version, app_data)
|
||||
|
||||
diff --git a/src/virtualenv/seed/wheels/periodic_update.py b/src/virtualenv/seed/wheels/periodic_update.py
|
||||
index f911882..b7b79d6 100644
|
||||
--- a/src/virtualenv/seed/wheels/periodic_update.py
|
||||
+++ b/src/virtualenv/seed/wheels/periodic_update.py
|
||||
@@ -15,7 +15,8 @@ from shutil import copy2
|
||||
from subprocess import DEVNULL, Popen
|
||||
from textwrap import dedent
|
||||
from threading import Thread
|
||||
-from typing import TYPE_CHECKING
|
||||
+from time import sleep
|
||||
+from typing import TYPE_CHECKING, Final
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
@@ -361,40 +362,53 @@ def release_date_for_wheel_path(dest: Path) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
+#: Pauses between PyPI metadata lookups before refusing a wheel we could not verify, to ride out a brief outage or a JSON
|
||||
+#: API that has not caught up with a fresh upload.
|
||||
+_DIGEST_RETRY_DELAYS: Final[tuple[float, ...]] = (1.0, 2.0, 4.0)
|
||||
+
|
||||
+
|
||||
def verify_wheel_digest(wheel: Wheel) -> None:
|
||||
"""Verify a downloaded wheel's sha256 against the digest PyPI's JSON API reports for that release.
|
||||
|
||||
``pip download`` trusts whatever index it is configured to use, and nothing else in this module checks the bytes it
|
||||
- hands back. A missing PyPI record only means this check cannot run - the wheel may come from a private index PyPI
|
||||
- has never heard of - so that case is logged and let through. An actual mismatch means the file on disk is not the
|
||||
- release PyPI published under that filename, which is what a compromised index, a stale mirror, or a MITM'd download
|
||||
- would produce, so that case is fatal: the wheel must never be cached or seeded into a venv.
|
||||
+ hands back. Callers run this only when pip used the default index, so PyPI served the file and must list its digest.
|
||||
+ A failed metadata request, an unknown filename or a missing sha256 therefore fails like a mismatch: accepting the
|
||||
+ wheel in those cases would let anyone who can block ``pypi.org/pypi`` turn the check off.
|
||||
|
||||
- :raises RuntimeError: if PyPI's record for this exact filename exists and the digest does not match.
|
||||
+ :raises UnverifiedWheelError: if PyPI's sha256 for this exact filename cannot be obtained after retrying.
|
||||
+ :raises RuntimeError: if the sha256 does not match.
|
||||
|
||||
"""
|
||||
- entry = _pypi_release_entry_for_wheel(wheel)
|
||||
- if entry is None:
|
||||
- LOGGER.debug("could not verify %s against PyPI: no matching release record", wheel.name)
|
||||
- return
|
||||
- digests = entry.get("digests")
|
||||
- expected = digests.get("sha256") if isinstance(digests, dict) else None
|
||||
- if not isinstance(expected, str):
|
||||
- LOGGER.debug("could not verify %s against PyPI: no sha256 digest published", wheel.name)
|
||||
- return
|
||||
- actual = hashlib.sha256(wheel.path.read_bytes()).hexdigest()
|
||||
- if actual != expected:
|
||||
+ expected = _pypi_sha256_for_wheel(wheel)
|
||||
+ for delay in _DIGEST_RETRY_DELAYS:
|
||||
+ if expected is not None:
|
||||
+ break
|
||||
+ LOGGER.warning("could not obtain the sha256 of %s from PyPI, retrying in %s seconds", wheel.name, delay)
|
||||
+ _PYPI_CACHE.pop(wheel.distribution, None)
|
||||
+ sleep(delay)
|
||||
+ expected = _pypi_sha256_for_wheel(wheel)
|
||||
+ if expected is None:
|
||||
+ msg = f"refusing downloaded wheel {wheel.name}: could not obtain its sha256 from PyPI to verify it"
|
||||
+ raise UnverifiedWheelError(msg)
|
||||
+ if (actual := hashlib.sha256(wheel.path.read_bytes()).hexdigest()) != expected:
|
||||
msg = f"downloaded wheel {wheel.name} has sha256 {actual}, but PyPI reports {expected} for this release"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
-def _pypi_release_entry_for_wheel(wheel: Wheel) -> dict[str, object] | None:
|
||||
+class UnverifiedWheelError(RuntimeError):
|
||||
+ """PyPI's digest for a downloaded wheel was unavailable, so a caller may fall back to the bundled wheel."""
|
||||
+
|
||||
+
|
||||
+def _pypi_sha256_for_wheel(wheel: Wheel) -> str | None:
|
||||
content = _pypi_get_distribution_info_cached(wheel.distribution)
|
||||
releases = content.get("releases") if isinstance(content, dict) else None
|
||||
entries = releases.get(wheel.version) if isinstance(releases, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
- return next((entry for entry in entries if isinstance(entry, dict) and entry.get("filename") == wheel.name), None)
|
||||
+ entry = next((entry for entry in entries if isinstance(entry, dict) and entry.get("filename") == wheel.name), {})
|
||||
+ digests = entry.get("digests")
|
||||
+ sha256 = digests.get("sha256") if isinstance(digests, dict) else None
|
||||
+ return sha256 if isinstance(sha256, str) else None
|
||||
|
||||
|
||||
def _request_context() -> Generator[ssl.SSLContext | None, None, None]:
|
||||
@@ -481,6 +495,7 @@ def _run_manual_upgrade(app_data: AppData, distribution: str, for_py_version: st
|
||||
|
||||
__all__ = [
|
||||
"NewVersion",
|
||||
+ "UnverifiedWheelError",
|
||||
"UpdateLog",
|
||||
"add_wheel_to_update_log",
|
||||
"do_update",
|
||||
diff --git a/tests/unit/seed/wheels/test_acquire.py b/tests/unit/seed/wheels/test_acquire.py
|
||||
index b9f7b5b..7122070 100644
|
||||
--- a/tests/unit/seed/wheels/test_acquire.py
|
||||
+++ b/tests/unit/seed/wheels/test_acquire.py
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from virtualenv.app_data import AppDataDiskFolder
|
||||
from virtualenv.seed.wheels.acquire import download_wheel, get_wheel, pip_wheel_env_run
|
||||
from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, get_embed_wheel
|
||||
-from virtualenv.seed.wheels.periodic_update import dump_datetime
|
||||
+from virtualenv.seed.wheels.periodic_update import UnverifiedWheelError, dump_datetime
|
||||
from virtualenv.seed.wheels.util import Wheel, discover_wheels
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -184,6 +184,26 @@ def test_get_wheel_download_not_called(mocker, for_py_version, session_app_data,
|
||||
assert write.call_count == 0
|
||||
|
||||
|
||||
+def test_get_wheel_falls_back_to_bundle_when_unverified(
|
||||
+ mocker: MockerFixture, for_py_version: str, session_app_data: AppDataDiskFolder
|
||||
+) -> None:
|
||||
+ mocker.patch("virtualenv.seed.wheels.acquire.download_wheel", side_effect=UnverifiedWheelError("refusing"))
|
||||
+
|
||||
+ wheel = get_wheel("setuptools", "bundle", for_py_version, [], True, session_app_data, False, os.environ)
|
||||
+
|
||||
+ assert wheel is not None
|
||||
+ assert wheel.name == get_embed_wheel("setuptools", for_py_version).name
|
||||
+
|
||||
+
|
||||
+def test_get_wheel_unverified_pinned_version_fails(
|
||||
+ mocker: MockerFixture, for_py_version: str, session_app_data: AppDataDiskFolder
|
||||
+) -> None:
|
||||
+ mocker.patch("virtualenv.seed.wheels.acquire.download_wheel", side_effect=UnverifiedWheelError("refusing"))
|
||||
+
|
||||
+ with pytest.raises(UnverifiedWheelError, match="refusing"):
|
||||
+ get_wheel("setuptools", "0.0.0", for_py_version, [], True, session_app_data, False, os.environ)
|
||||
+
|
||||
+
|
||||
def test_get_wheel_download_cached(
|
||||
tmp_path: Path,
|
||||
mocker: MockerFixture,
|
||||
diff --git a/tests/unit/seed/wheels/test_periodic_update.py b/tests/unit/seed/wheels/test_periodic_update.py
|
||||
index 6a54455..a53eafc 100644
|
||||
--- a/tests/unit/seed/wheels/test_periodic_update.py
|
||||
+++ b/tests/unit/seed/wheels/test_periodic_update.py
|
||||
@@ -12,6 +12,7 @@ from io import StringIO
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
+from typing import TYPE_CHECKING
|
||||
from urllib.error import URLError
|
||||
|
||||
import pytest
|
||||
@@ -22,6 +23,7 @@ from virtualenv.seed.wheels import Wheel
|
||||
from virtualenv.seed.wheels.embed import BUNDLE_SUPPORT, get_embed_wheel
|
||||
from virtualenv.seed.wheels.periodic_update import (
|
||||
NewVersion,
|
||||
+ UnverifiedWheelError,
|
||||
UpdateLog,
|
||||
do_update,
|
||||
dump_datetime,
|
||||
@@ -34,12 +36,26 @@ from virtualenv.seed.wheels.periodic_update import (
|
||||
)
|
||||
from virtualenv.util.subprocess import CREATE_NO_WINDOW
|
||||
|
||||
+if TYPE_CHECKING:
|
||||
+ from collections.abc import Generator
|
||||
+ from unittest.mock import MagicMock
|
||||
+
|
||||
+ from pytest_mock import MockerFixture
|
||||
+
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
-def _clear_pypi_info_cache() -> None:
|
||||
+def _clear_pypi_info_cache() -> Generator[None, None, None]:
|
||||
from virtualenv.seed.wheels.periodic_update import _PYPI_CACHE # noqa: PLC0415
|
||||
|
||||
_PYPI_CACHE.clear()
|
||||
+ yield
|
||||
+ # the mocked responses must not leak into later tests that verify real downloads in this process
|
||||
+ _PYPI_CACHE.clear()
|
||||
+
|
||||
+
|
||||
+@pytest.fixture
|
||||
+def retry_sleep(mocker: MockerFixture) -> MagicMock:
|
||||
+ return mocker.patch("virtualenv.seed.wheels.periodic_update.sleep")
|
||||
|
||||
|
||||
def test_manual_upgrade(session_app_data, caplog, mocker, for_py_version) -> None:
|
||||
@@ -572,20 +588,36 @@ def test_verify_wheel_digest_mismatch(tmp_path, mocker) -> None:
|
||||
pytest.param("pip-20.1-py3-none-any.whl", {"sha256": "0" * 64}, "19.0", id="version_not_in_releases"),
|
||||
],
|
||||
)
|
||||
-def test_verify_wheel_digest_skips_when_unverifiable(tmp_path, mocker, filename, digests, version) -> None:
|
||||
+@pytest.mark.usefixtures("retry_sleep")
|
||||
+def test_verify_wheel_digest_rejects_when_unverifiable(tmp_path, mocker, filename, digests, version) -> None:
|
||||
wheel = Wheel(tmp_path / "pip-20.1-py3-none-any.whl")
|
||||
wheel.path.write_bytes(b"content")
|
||||
_pypi_release_response(mocker, filename, digests, version)
|
||||
|
||||
- verify_wheel_digest(wheel) # must not raise: nothing to compare against
|
||||
+ with pytest.raises(UnverifiedWheelError, match=r"could not obtain its sha256 from PyPI"):
|
||||
+ verify_wheel_digest(wheel)
|
||||
|
||||
|
||||
-def test_verify_wheel_digest_skips_on_pypi_lookup_failure(tmp_path, mocker) -> None:
|
||||
+def test_verify_wheel_digest_rejects_after_retrying_pypi_lookup(tmp_path, mocker, retry_sleep) -> None:
|
||||
wheel = Wheel(tmp_path / "pip-20.1-py3-none-any.whl")
|
||||
wheel.path.write_bytes(b"content")
|
||||
mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", side_effect=URLError("offline"))
|
||||
|
||||
- verify_wheel_digest(wheel) # must not raise: a private mirror PyPI never heard of is legitimate
|
||||
+ with pytest.raises(UnverifiedWheelError, match=r"could not obtain its sha256 from PyPI"):
|
||||
+ verify_wheel_digest(wheel)
|
||||
+
|
||||
+ assert [entry.args for entry in retry_sleep.call_args_list] == [(1.0,), (2.0,), (4.0,)]
|
||||
+
|
||||
+
|
||||
+@pytest.mark.usefixtures("retry_sleep")
|
||||
+def test_verify_wheel_digest_recovers_on_retry(tmp_path, mocker) -> None:
|
||||
+ wheel = Wheel(tmp_path / "pip-20.1-py3-none-any.whl")
|
||||
+ wheel.path.write_bytes(b"content")
|
||||
+ body = json.dumps({"releases": {"20.1": [{"filename": wheel.name, "digests": {"sha256": "0" * 64}}]}})
|
||||
+ mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", side_effect=[URLError("offline"), StringIO(body)])
|
||||
+
|
||||
+ with pytest.raises(RuntimeError, match=r"has sha256 .* but PyPI reports 0{64}"):
|
||||
+ verify_wheel_digest(wheel)
|
||||
|
||||
|
||||
def mock_download(mocker, pip_version_remote):
|
||||
--
|
||||
2.35.6
|
||||
@@ -0,0 +1,777 @@
|
||||
From 7babec66e043e1cc8bc66869a063653898719d96 Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
Date: Sat, 17 Oct 2026 03:30:06 -0700
|
||||
Subject: [PATCH] fix(seed): verify wheels before caching (#3430)
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
CVE: CVE-2026-102930 CVE-2026-113714 CVE-2026-113717
|
||||
Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/7babec66e043e1cc8bc66869a063653898719d96]
|
||||
|
||||
Backport Changes:
|
||||
- Keep the 21.2.1 python -m pip invocation; the newer PIP_RUN launcher
|
||||
belongs to a separate upstream change and is not needed for staged
|
||||
verification.
|
||||
- Retain the existing downloader argument interfaces instead of adding
|
||||
newer distribution/version validation helpers. Adapt the downloader
|
||||
docstring accordingly, keeping the staged-file and digest checks.
|
||||
- Declare packaging>=23.1 as a runtime dependency for the new wheel
|
||||
project/version checks; 21.2.1 only declared it for its test suite.
|
||||
- Retain the Python 3.8 wheel-seeding case in the adapted pip-seeder
|
||||
tests. Omit the upstream local-pip-module test, which depends on the
|
||||
separate PIP_RUN launcher change.
|
||||
- Add regressions for forged pip Saved output, malformed staging
|
||||
contents, and the pip seeder's private wheel selection and download
|
||||
policy. Update existing tests for offline pinned-wheel installation.
|
||||
- Omit the upstream release changelog fragment.
|
||||
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
pyproject.toml | 1 +
|
||||
src/virtualenv/seed/embed/pip_invoke.py | 54 +++--
|
||||
src/virtualenv/seed/wheels/acquire.py | 101 ++++----
|
||||
tests/unit/seed/embed/test_pip_invoke.py | 151 ++++++------
|
||||
tests/unit/seed/wheels/test_acquire.py | 288 ++++++++++++++---------
|
||||
5 files changed, 350 insertions(+), 245 deletions(-)
|
||||
|
||||
diff --git a/pyproject.toml b/pyproject.toml
|
||||
index 471684f..0b96c7a 100644
|
||||
--- a/pyproject.toml
|
||||
+++ b/pyproject.toml
|
||||
@@ -49,6 +49,7 @@ dependencies = [
|
||||
"filelock>=3.16.1,<=3.19.1; python_version<'3.10'",
|
||||
"filelock>=3.24.2,<4; python_version>='3.10'",
|
||||
"importlib-metadata>=6.6; python_version<'3.8'",
|
||||
+ "packaging>=23.1",
|
||||
"platformdirs>=3.9.1,<5",
|
||||
"python-discovery>=1",
|
||||
"typing-extensions>=4.13.2; python_version<'3.11'",
|
||||
diff --git a/src/virtualenv/seed/embed/pip_invoke.py b/src/virtualenv/seed/embed/pip_invoke.py
|
||||
index fb44229..1f333d3 100644
|
||||
--- a/src/virtualenv/seed/embed/pip_invoke.py
|
||||
+++ b/src/virtualenv/seed/embed/pip_invoke.py
|
||||
@@ -2,16 +2,19 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
+from pathlib import Path
|
||||
+from shutil import copyfile
|
||||
from subprocess import Popen
|
||||
-from typing import TYPE_CHECKING
|
||||
+from tempfile import TemporaryDirectory
|
||||
+from typing import TYPE_CHECKING, Final
|
||||
|
||||
from virtualenv.seed.embed.base_embed import BaseEmbed
|
||||
-from virtualenv.seed.wheels import Version, get_wheel, pip_wheel_env_run
|
||||
+from virtualenv.seed.wheels import get_wheel, pip_wheel_env_run
|
||||
+from virtualenv.seed.wheels.util import Version
|
||||
from virtualenv.util.subprocess import LogCmd
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
- from pathlib import Path
|
||||
|
||||
from virtualenv.config.cli.parser import VirtualEnvOptions
|
||||
from virtualenv.create.creator import Creator
|
||||
@@ -43,7 +46,7 @@ class PipInvoke(BaseEmbed):
|
||||
|
||||
@contextmanager
|
||||
def get_pip_install_cmd(self, exe: Path, for_py_version: str) -> Generator[list[str], None, None]:
|
||||
- cmd = [
|
||||
+ cmd: Final = [
|
||||
str(exe),
|
||||
"-m",
|
||||
"pip",
|
||||
@@ -53,29 +56,28 @@ class PipInvoke(BaseEmbed):
|
||||
":all:",
|
||||
"--disable-pip-version-check",
|
||||
"--ignore-installed",
|
||||
+ "--no-index",
|
||||
]
|
||||
- if not self.download:
|
||||
- cmd.append("--no-index")
|
||||
- folders = set()
|
||||
- for dist, version in self.distribution_to_versions().items():
|
||||
- wheel = get_wheel(
|
||||
- distribution=dist,
|
||||
- version=version,
|
||||
- for_py_version=for_py_version,
|
||||
- search_dirs=self.extra_search_dir,
|
||||
- download=False,
|
||||
- app_data=self.app_data,
|
||||
- do_periodic_update=self.periodic_update,
|
||||
- env=self.env,
|
||||
- )
|
||||
- if wheel is None:
|
||||
- msg = f"could not get wheel for distribution {dist}"
|
||||
- raise RuntimeError(msg)
|
||||
- folders.add(str(wheel.path.parent))
|
||||
- cmd.append(Version.as_pip_req(dist, wheel.version))
|
||||
- for folder in sorted(folders):
|
||||
- cmd.extend(["--find-links", str(folder)])
|
||||
- yield cmd
|
||||
+ # Only the selected archives may participate in pip's filename-based selection.
|
||||
+ with TemporaryDirectory(prefix="virtualenv-seed-") as folder:
|
||||
+ cmd.extend(["--find-links", folder])
|
||||
+ for dist, version in self.distribution_to_versions().items():
|
||||
+ wheel: Final = get_wheel(
|
||||
+ distribution=dist,
|
||||
+ version=version,
|
||||
+ for_py_version=for_py_version,
|
||||
+ search_dirs=self.extra_search_dir,
|
||||
+ download=self.download,
|
||||
+ app_data=self.app_data,
|
||||
+ do_periodic_update=self.periodic_update,
|
||||
+ env=self.env,
|
||||
+ )
|
||||
+ if wheel is None:
|
||||
+ msg: Final = f"could not get wheel for distribution {dist}"
|
||||
+ raise RuntimeError(msg)
|
||||
+ copyfile(wheel.path, Path(folder) / wheel.name)
|
||||
+ cmd.append(Version.as_pip_req(dist, wheel.version))
|
||||
+ yield cmd
|
||||
|
||||
|
||||
__all__ = [
|
||||
diff --git a/src/virtualenv/seed/wheels/acquire.py b/src/virtualenv/seed/wheels/acquire.py
|
||||
index 9127db1..9b394b9 100644
|
||||
--- a/src/virtualenv/seed/wheels/acquire.py
|
||||
+++ b/src/virtualenv/seed/wheels/acquire.py
|
||||
@@ -7,8 +7,12 @@ import sys
|
||||
from operator import eq, lt
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, CalledProcessError, Popen
|
||||
+from tempfile import TemporaryDirectory
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
+from packaging.specifiers import Specifier
|
||||
+from packaging.utils import canonicalize_name
|
||||
+
|
||||
from .bundle import from_bundle
|
||||
from .periodic_update import UnverifiedWheelError, add_wheel_to_update_log, verify_wheel_digest
|
||||
from .util import Version, Wheel, discover_wheels
|
||||
@@ -83,54 +87,67 @@ def download_wheel( # noqa: PLR0913
|
||||
to_folder: Path,
|
||||
env: dict[str, str],
|
||||
) -> Wheel:
|
||||
- to_download = f"{distribution}{version_spec or ''}"
|
||||
+ """Download one wheel into a private staging directory and move it into ``to_folder`` once it checks out.
|
||||
+
|
||||
+ :raises CalledProcessError: when ``pip download`` fails.
|
||||
+ :raises UnverifiedWheelError: when PyPI cannot supply the digest to verify against.
|
||||
+ :raises RuntimeError: when the staged file is not the requested wheel, its digest differs from PyPI's, or it does
|
||||
+ not support ``for_py_version``; nothing reaches ``to_folder`` in these cases.
|
||||
+
|
||||
+ """
|
||||
+ to_download: Final = f"{distribution}{version_spec or ''}"
|
||||
LOGGER.debug("download wheel %s %s to %s", to_download, for_py_version, to_folder)
|
||||
- cmd = [
|
||||
- sys.executable,
|
||||
- "-m",
|
||||
- "pip",
|
||||
- "download",
|
||||
- "--progress-bar",
|
||||
- "off",
|
||||
- "--disable-pip-version-check",
|
||||
- "--only-binary=:all:",
|
||||
- "--no-deps",
|
||||
- "--python-version",
|
||||
- for_py_version,
|
||||
- "-d",
|
||||
- str(to_folder),
|
||||
- to_download,
|
||||
- ]
|
||||
- verify_against_pypi = _uses_default_index(env)
|
||||
- # pip has no interface in python - must be a new sub-process
|
||||
+ verify_against_pypi: Final = _uses_default_index(env)
|
||||
env = pip_wheel_env_run(search_dirs, app_data, env)
|
||||
- process = Popen(cmd, env=env, stdout=PIPE, stderr=PIPE, universal_newlines=True, encoding="utf-8")
|
||||
- out, err = process.communicate()
|
||||
- if process.returncode != 0:
|
||||
- kwargs = {"output": out, "stderr": err}
|
||||
- raise CalledProcessError(process.returncode, cmd, **kwargs)
|
||||
- result = _find_downloaded_wheel(distribution, version_spec, for_py_version, to_folder, out)
|
||||
- if result is None:
|
||||
- msg = f"could not find downloaded wheel for {to_download}"
|
||||
+ to_folder.mkdir(parents=True, exist_ok=True)
|
||||
+ with TemporaryDirectory(dir=to_folder, prefix="download-") as staging:
|
||||
+ cmd: Final = [
|
||||
+ sys.executable,
|
||||
+ "-m",
|
||||
+ "pip",
|
||||
+ "download",
|
||||
+ "--progress-bar",
|
||||
+ "off",
|
||||
+ "--disable-pip-version-check",
|
||||
+ "--only-binary=:all:",
|
||||
+ "--no-deps",
|
||||
+ "--python-version",
|
||||
+ for_py_version,
|
||||
+ "-d",
|
||||
+ staging,
|
||||
+ to_download,
|
||||
+ ]
|
||||
+ process: Final = Popen(cmd, env=env, stdout=PIPE, stderr=PIPE, universal_newlines=True, encoding="utf-8")
|
||||
+ out, err = process.communicate()
|
||||
+ if process.returncode != 0:
|
||||
+ raise CalledProcessError(process.returncode, cmd, output=out, stderr=err)
|
||||
+ result: Final = _downloaded_wheel(Path(staging), distribution, version_spec)
|
||||
+ if verify_against_pypi:
|
||||
+ verify_wheel_digest(result)
|
||||
+ if not result.support_py(for_py_version):
|
||||
+ msg: Final = f"downloaded wheel {result.name} does not support Python {for_py_version}"
|
||||
+ raise RuntimeError(msg)
|
||||
+ destination: Final = to_folder / result.name
|
||||
+ result.path.replace(destination)
|
||||
+ return Wheel(destination)
|
||||
+
|
||||
+
|
||||
+def _downloaded_wheel(folder: Path, distribution: str, version_spec: str | None) -> Wheel:
|
||||
+ files: Final = list(folder.iterdir())
|
||||
+ if len(files) != 1 or files[0].is_symlink() or not files[0].is_file():
|
||||
+ msg: Final = f"could not find downloaded wheel for {distribution}: expected one archive"
|
||||
+ raise RuntimeError(msg)
|
||||
+ result: Final = Wheel.from_path(files[0])
|
||||
+ if (
|
||||
+ result is None
|
||||
+ or canonicalize_name(result.distribution) != canonicalize_name(distribution)
|
||||
+ or (version_spec and not Specifier(version_spec).contains(result.version))
|
||||
+ ):
|
||||
+ msg: Final = f"unexpected downloaded wheel {files[0].name} for {distribution}{version_spec or ''}"
|
||||
raise RuntimeError(msg)
|
||||
- LOGGER.debug("downloaded wheel %s", result.name)
|
||||
- if verify_against_pypi:
|
||||
- verify_wheel_digest(result)
|
||||
return result
|
||||
|
||||
|
||||
-def _find_downloaded_wheel(
|
||||
- distribution: str, version_spec: str | None, for_py_version: str, to_folder: Path, out: str
|
||||
-) -> Wheel | None:
|
||||
- for line in out.splitlines():
|
||||
- stripped_line = line.lstrip()
|
||||
- for marker in ("Saved ", "File was already downloaded "):
|
||||
- if stripped_line.startswith(marker):
|
||||
- return Wheel(Path(stripped_line[len(marker) :]).absolute())
|
||||
- # if for some reason the output does not match fallback to the latest version with that spec
|
||||
- return find_compatible_in_house(distribution, version_spec, for_py_version, to_folder)
|
||||
-
|
||||
-
|
||||
def find_compatible_in_house(
|
||||
distribution: str, version_spec: str | None, for_py_version: str, in_folder: Path
|
||||
) -> Wheel | None:
|
||||
diff --git a/tests/unit/seed/embed/test_pip_invoke.py b/tests/unit/seed/embed/test_pip_invoke.py
|
||||
index 6753fc2..9892bc9 100644
|
||||
--- a/tests/unit/seed/embed/test_pip_invoke.py
|
||||
+++ b/tests/unit/seed/embed/test_pip_invoke.py
|
||||
@@ -1,93 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
-import itertools
|
||||
+import subprocess
|
||||
import sys
|
||||
-from shutil import copy2
|
||||
+from pathlib import Path
|
||||
+from types import SimpleNamespace
|
||||
+from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from virtualenv.run import cli_run
|
||||
from virtualenv.seed.embed.pip_invoke import PipInvoke
|
||||
-from virtualenv.seed.wheels.bundle import load_embed_wheel
|
||||
-from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SUPPORT
|
||||
+from virtualenv.seed.wheels.embed import get_embed_wheel
|
||||
|
||||
|
||||
-@pytest.mark.slow
|
||||
-@pytest.mark.parametrize("no", ["pip", "setuptools", "wheel", ""])
|
||||
-def test_base_bootstrap_via_pip_invoke(tmp_path, coverage_env, mocker, current_fastest, no) -> None: # noqa: C901
|
||||
- extra_search_dir = tmp_path / "extra"
|
||||
- extra_search_dir.mkdir()
|
||||
- for_py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
- new = BUNDLE_SUPPORT[for_py_version]
|
||||
- for wheel_filename in BUNDLE_SUPPORT[for_py_version].values():
|
||||
- copy2(str(BUNDLE_FOLDER / wheel_filename), str(extra_search_dir))
|
||||
-
|
||||
- def _load_embed_wheel(app_data, distribution, _for_py_version, version):
|
||||
- return load_embed_wheel(app_data, distribution, old_ver, version)
|
||||
-
|
||||
- old_ver = "3.8"
|
||||
- old = BUNDLE_SUPPORT[old_ver]
|
||||
- mocker.patch("virtualenv.seed.wheels.bundle.load_embed_wheel", side_effect=_load_embed_wheel)
|
||||
-
|
||||
- def _execute(cmd, env):
|
||||
- expected = set()
|
||||
- for distribution, with_version in versions.items():
|
||||
- if distribution == no:
|
||||
- continue
|
||||
- if with_version == "embed" or old[distribution] == new[distribution]:
|
||||
- expected.add(BUNDLE_FOLDER)
|
||||
- else:
|
||||
- expected.add(extra_search_dir)
|
||||
- expected_list = list(
|
||||
- itertools.chain.from_iterable(["--find-links", str(e)] for e in sorted(expected, key=str)),
|
||||
- )
|
||||
- found = cmd[-len(expected_list) :] if expected_list else []
|
||||
- assert "--no-index" not in cmd
|
||||
- cmd.append("--no-index")
|
||||
- assert found == expected_list
|
||||
- return original(cmd, env)
|
||||
-
|
||||
- original = PipInvoke._execute # noqa: SLF001
|
||||
- run = mocker.patch.object(PipInvoke, "_execute", side_effect=_execute)
|
||||
- versions = {"pip": "embed", "setuptools": "bundle"}
|
||||
- if sys.version_info[:2] == (3, 8):
|
||||
- versions["wheel"] = new["wheel"].split("-")[1]
|
||||
-
|
||||
- create_cmd = [
|
||||
+@pytest.mark.parametrize(
|
||||
+ "excluded",
|
||||
+ [
|
||||
+ pytest.param("pip", id="no-pip"),
|
||||
+ pytest.param("setuptools", id="no-setuptools"),
|
||||
+ pytest.param("wheel", id="no-wheel"),
|
||||
+ pytest.param("", id="all"),
|
||||
+ ],
|
||||
+)
|
||||
+def test_base_bootstrap_via_pip_invoke(tmp_path: Path, current_fastest: str, excluded: str) -> None:
|
||||
+ command: Final = [
|
||||
"--seeder",
|
||||
"pip",
|
||||
str(tmp_path / "env"),
|
||||
"--download",
|
||||
"--creator",
|
||||
current_fastest,
|
||||
- "--extra-search-dir",
|
||||
- str(extra_search_dir),
|
||||
- "--app-data",
|
||||
- str(tmp_path / "app-data"),
|
||||
+ "--pip",
|
||||
+ "embed",
|
||||
+ "--setuptools",
|
||||
+ "embed",
|
||||
+ "--no-periodic-update",
|
||||
]
|
||||
- for dist, version in versions.items():
|
||||
- create_cmd.extend([f"--{dist}", version])
|
||||
- if no:
|
||||
- create_cmd.append(f"--no-{no}")
|
||||
- result = cli_run(create_cmd)
|
||||
- coverage_env()
|
||||
+ expected = {"pip", "setuptools"}
|
||||
+ if sys.version_info[:2] == (3, 8):
|
||||
+ command.extend(["--wheel", "embed"])
|
||||
+ expected.add("wheel")
|
||||
+ if excluded:
|
||||
+ command.append(f"--no-{excluded}")
|
||||
+ session: Final = cli_run(command)
|
||||
+ assert {path.name for path in session.creator.purelib.iterdir()} & expected == expected - {excluded}
|
||||
+
|
||||
+
|
||||
+def test_pip_invoke_reports_unavailable_wheel(tmp_path: Path) -> None:
|
||||
+ with pytest.raises(RuntimeError, match="could not get wheel for distribution pip"):
|
||||
+ cli_run([
|
||||
+ str(tmp_path / "env"),
|
||||
+ "--seeder",
|
||||
+ "pip",
|
||||
+ "--pip",
|
||||
+ "0.0.0",
|
||||
+ "--no-download",
|
||||
+ "--no-periodic-update",
|
||||
+ ])
|
||||
|
||||
- assert result
|
||||
- assert run.call_count == 1
|
||||
|
||||
- site_package = result.creator.purelib
|
||||
- pip = site_package / "pip"
|
||||
- setuptools = site_package / "setuptools"
|
||||
- wheel = site_package / "wheel"
|
||||
- files_post_first_create = list(site_package.iterdir())
|
||||
+@pytest.mark.usefixtures("_no_coverage")
|
||||
+def test_pip_invoke_freeze_uses_version_pin(tmp_path: Path) -> None:
|
||||
+ session: Final = cli_run([
|
||||
+ str(tmp_path / "env"),
|
||||
+ "--seeder",
|
||||
+ "pip",
|
||||
+ "--pip",
|
||||
+ "embed",
|
||||
+ "--no-setuptools",
|
||||
+ "--no-periodic-update",
|
||||
+ ])
|
||||
+ result: Final = subprocess.run(
|
||||
+ [str(session.creator.exe), "-I", "-m", "pip", "freeze", "--all"],
|
||||
+ capture_output=True,
|
||||
+ text=True,
|
||||
+ encoding="utf-8",
|
||||
+ check=True,
|
||||
+ )
|
||||
+ version: Final = get_embed_wheel("pip", f"{sys.version_info.major}.{sys.version_info.minor}").version
|
||||
+ assert f"pip=={version}" in result.stdout.splitlines()
|
||||
|
||||
- if no:
|
||||
- no_file = locals()[no]
|
||||
- assert no not in files_post_first_create
|
||||
|
||||
- for key in ("pip", "setuptools", "wheel"):
|
||||
- if key == no:
|
||||
- continue
|
||||
- if sys.version_info[:2] >= (3, 9) and key == "wheel":
|
||||
- continue
|
||||
- assert locals()[key] in files_post_first_create
|
||||
+@pytest.mark.parametrize("download", [False, True])
|
||||
+def test_pip_invoke_only_installs_selected_verified_archives(mocker, for_py_version, download) -> None:
|
||||
+ wheel = get_embed_wheel("pip", for_py_version)
|
||||
+ get_wheel = mocker.patch("virtualenv.seed.embed.pip_invoke.get_wheel", return_value=wheel)
|
||||
+ seeder = SimpleNamespace(
|
||||
+ download=download, extra_search_dir=[], app_data=object(),
|
||||
+ periodic_update=False, env={},
|
||||
+ distribution_to_versions=lambda: {"pip": "bundle"},
|
||||
+ )
|
||||
+ with PipInvoke.get_pip_install_cmd(seeder, Path(sys.executable), for_py_version) as command:
|
||||
+ assert "--no-index" in command
|
||||
+ assert get_wheel.call_args.kwargs["download"] is download
|
||||
+ folder = Path(command[command.index("--find-links") + 1])
|
||||
+ assert list(folder.iterdir()) == [folder / wheel.name]
|
||||
+ assert (folder / wheel.name).read_bytes() == wheel.path.read_bytes()
|
||||
+ assert f"pip=={wheel.version}" in command
|
||||
+ assert not folder.exists()
|
||||
diff --git a/tests/unit/seed/wheels/test_acquire.py b/tests/unit/seed/wheels/test_acquire.py
|
||||
index 7122070..8b03310 100644
|
||||
--- a/tests/unit/seed/wheels/test_acquire.py
|
||||
+++ b/tests/unit/seed/wheels/test_acquire.py
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
+import hashlib
|
||||
+import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
+from io import BytesIO
|
||||
from pathlib import Path
|
||||
-from subprocess import CalledProcessError
|
||||
-from typing import TYPE_CHECKING
|
||||
+from subprocess import CalledProcessError, Popen
|
||||
+from typing import TYPE_CHECKING, Final
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -13,10 +16,11 @@ from virtualenv.app_data import AppDataDiskFolder
|
||||
from virtualenv.seed.wheels.acquire import download_wheel, get_wheel, pip_wheel_env_run
|
||||
from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, get_embed_wheel
|
||||
from virtualenv.seed.wheels.periodic_update import UnverifiedWheelError, dump_datetime
|
||||
-from virtualenv.seed.wheels.util import Wheel, discover_wheels
|
||||
+from virtualenv.seed.wheels.util import Wheel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
+ from ssl import SSLContext
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
@@ -33,124 +37,198 @@ def test_pip_wheel_env_run_could_not_find(session_app_data, mocker) -> None:
|
||||
pip_wheel_env_run([], session_app_data, os.environ)
|
||||
|
||||
|
||||
-def test_download_wheel_bad_output(mocker, for_py_version, session_app_data) -> None:
|
||||
- """if the download contains no match for what wheel was downloaded, pick one that matches from target"""
|
||||
- distribution = "setuptools"
|
||||
- p_open = mocker.MagicMock()
|
||||
- mocker.patch("virtualenv.seed.wheels.acquire.Popen", return_value=p_open)
|
||||
- mocker.patch("virtualenv.seed.wheels.acquire.verify_wheel_digest")
|
||||
- p_open.communicate.return_value = "", ""
|
||||
- p_open.returncode = 0
|
||||
-
|
||||
- embed = get_embed_wheel(distribution, for_py_version)
|
||||
- as_path = mocker.MagicMock()
|
||||
- available = discover_wheels(BUNDLE_FOLDER, "setuptools", None, for_py_version)
|
||||
- as_path.iterdir.return_value = [i.path for i in available]
|
||||
-
|
||||
- result = download_wheel(
|
||||
- distribution,
|
||||
- f"=={embed.version}",
|
||||
- for_py_version,
|
||||
- [],
|
||||
- session_app_data,
|
||||
- as_path,
|
||||
- os.environ,
|
||||
+@pytest.mark.parametrize(
|
||||
+ "version_spec",
|
||||
+ [
|
||||
+ pytest.param(lambda _wheel: None, id="latest"),
|
||||
+ pytest.param(lambda _wheel: "", id="unpinned"),
|
||||
+ pytest.param(lambda wheel: f"=={wheel.version}", id="pinned"),
|
||||
+ ],
|
||||
+)
|
||||
+def test_download_wheel_uses_staged_archive(
|
||||
+ download_process: tuple[MagicMock, Wheel, bytearray],
|
||||
+ for_py_version: str,
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+ version_spec: Callable[[Wheel], str | None],
|
||||
+) -> None:
|
||||
+ _, expected, _ = download_process
|
||||
+ result: Final = download_wheel("pip", version_spec(expected), for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ assert (result.path, result.path.read_bytes(), list(tmp_path.iterdir())) == (
|
||||
+ tmp_path / expected.name,
|
||||
+ expected.path.read_bytes(),
|
||||
+ [tmp_path / expected.name],
|
||||
)
|
||||
- assert result.path == embed.path
|
||||
-
|
||||
|
||||
-def test_download_wheel_not_found_raises(mocker, for_py_version, session_app_data) -> None:
|
||||
- """download_wheel must raise a clear error rather than crash when no wheel can be identified at all."""
|
||||
- p_open = mocker.MagicMock()
|
||||
- mocker.patch("virtualenv.seed.wheels.acquire.Popen", return_value=p_open)
|
||||
- p_open.communicate.return_value = "", ""
|
||||
- p_open.returncode = 0
|
||||
- as_path = mocker.MagicMock()
|
||||
- as_path.iterdir.return_value = []
|
||||
|
||||
+def test_download_wheel_not_found_raises(
|
||||
+ download_process: tuple[MagicMock, Wheel, bytearray],
|
||||
+ for_py_version: str,
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+) -> None:
|
||||
+ download_process[0].side_effect = None
|
||||
with pytest.raises(RuntimeError, match="could not find downloaded wheel"):
|
||||
- download_wheel("setuptools", None, for_py_version, [], session_app_data, as_path, os.environ)
|
||||
+ download_wheel("pip", None, for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ assert list(tmp_path.iterdir()) == []
|
||||
+
|
||||
+
|
||||
+@pytest.mark.usefixtures("download_process")
|
||||
+def test_download_wheel_rejects_wrong_version(
|
||||
+ for_py_version: str,
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+) -> None:
|
||||
+ with pytest.raises(RuntimeError, match="unexpected downloaded wheel"):
|
||||
+ download_wheel("pip", "==0.0.0", for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
-def test_download_fails(mocker, for_py_version, session_app_data) -> None:
|
||||
- p_open = mocker.MagicMock()
|
||||
- mocker.patch("virtualenv.seed.wheels.acquire.Popen", return_value=p_open)
|
||||
- p_open.communicate.return_value = "out", "err"
|
||||
- p_open.returncode = 1
|
||||
+@pytest.mark.usefixtures("download_process")
|
||||
+def test_download_wheel_rejects_unsupported_python(
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+) -> None:
|
||||
+ with pytest.raises(RuntimeError, match="does not support Python"):
|
||||
+ download_wheel("pip", None, "2.7", [], session_app_data, tmp_path, {})
|
||||
+ assert list(tmp_path.iterdir()) == []
|
||||
|
||||
- as_path = mocker.MagicMock()
|
||||
+
|
||||
+def test_download_fails(
|
||||
+ download_process: tuple[MagicMock, Wheel, bytearray],
|
||||
+ for_py_version: str,
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+) -> None:
|
||||
+ download_process[0].return_value.configure_mock(returncode=7)
|
||||
with pytest.raises(CalledProcessError) as context:
|
||||
- download_wheel("pip", "==1", for_py_version, [], session_app_data, as_path, os.environ)
|
||||
- exc = context.value
|
||||
- assert exc.output == "out"
|
||||
- assert exc.stderr == "err"
|
||||
- assert exc.returncode == 1
|
||||
- assert [
|
||||
- sys.executable,
|
||||
- "-m",
|
||||
- "pip",
|
||||
- "download",
|
||||
- "--progress-bar",
|
||||
- "off",
|
||||
- "--disable-pip-version-check",
|
||||
- "--only-binary=:all:",
|
||||
- "--no-deps",
|
||||
- "--python-version",
|
||||
- for_py_version,
|
||||
- "-d",
|
||||
- str(as_path),
|
||||
- "pip==1",
|
||||
- ] == exc.cmd
|
||||
-
|
||||
-
|
||||
-def test_download_wheel_python_io_encoding(mocker, for_py_version, session_app_data) -> None:
|
||||
- mock_popen = mocker.patch("virtualenv.seed.wheels.acquire.Popen")
|
||||
- mock_popen.return_value.communicate.return_value = "Saved a-b-c.whl", ""
|
||||
- mock_popen.return_value.returncode = 0
|
||||
- mocker.patch("pathlib.Path.absolute", return_value=Path("a-b-c.whl"))
|
||||
- mocker.patch("virtualenv.seed.wheels.acquire.verify_wheel_digest")
|
||||
-
|
||||
- download_wheel("pip", "==1", for_py_version, [], session_app_data, "folder", os.environ.copy())
|
||||
-
|
||||
- env = mock_popen.call_args[1]["env"]
|
||||
- assert env["PYTHONIOENCODING"] == "utf-8"
|
||||
-
|
||||
-
|
||||
-def test_download_wheel_verifies_digest(mocker, for_py_version, session_app_data) -> None:
|
||||
- """download_wheel must reject a wheel whose bytes do not match PyPI's published digest."""
|
||||
- mock_popen = mocker.patch("virtualenv.seed.wheels.acquire.Popen")
|
||||
- mock_popen.return_value.communicate.return_value = "Saved a-b-c.whl", ""
|
||||
- mock_popen.return_value.returncode = 0
|
||||
- mocker.patch("pathlib.Path.absolute", return_value=Path("a-b-c.whl"))
|
||||
- verify = mocker.patch(
|
||||
- "virtualenv.seed.wheels.acquire.verify_wheel_digest",
|
||||
- side_effect=RuntimeError("digest mismatch"),
|
||||
+ download_wheel("pip", None, for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ error: Final = context.value
|
||||
+ assert (error.output, error.stderr, error.returncode, error.cmd[:3], list(tmp_path.iterdir())) == (
|
||||
+ "progress complete",
|
||||
+ "",
|
||||
+ 7,
|
||||
+ [sys.executable, "-m", "pip"],
|
||||
+ [],
|
||||
)
|
||||
|
||||
- with pytest.raises(RuntimeError, match="digest mismatch"):
|
||||
- download_wheel("pip", "==1", for_py_version, [], session_app_data, "folder", os.environ.copy())
|
||||
|
||||
- assert verify.call_count == 1
|
||||
+def test_download_wheel_python_io_encoding(
|
||||
+ download_process: tuple[MagicMock, Wheel, bytearray],
|
||||
+ for_py_version: str,
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+) -> None:
|
||||
+ download_wheel("pip", None, for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ assert download_process[0].call_args.kwargs["env"]["PYTHONIOENCODING"] == "utf-8"
|
||||
|
||||
|
||||
-@pytest.mark.parametrize("env_var", ["PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_INDEX"])
|
||||
-def test_download_wheel_skips_digest_check_for_custom_index(mocker, for_py_version, session_app_data, env_var) -> None:
|
||||
- """A configured custom index may legitimately serve a same-named wheel PyPI never built."""
|
||||
- mock_popen = mocker.patch("virtualenv.seed.wheels.acquire.Popen")
|
||||
- mock_popen.return_value.communicate.return_value = "Saved a-b-c.whl", ""
|
||||
- mock_popen.return_value.returncode = 0
|
||||
- mocker.patch("pathlib.Path.absolute", return_value=Path("a-b-c.whl"))
|
||||
- verify = mocker.patch(
|
||||
- "virtualenv.seed.wheels.acquire.verify_wheel_digest",
|
||||
- side_effect=RuntimeError("would have rejected a legitimate wheel"),
|
||||
+def test_download_wheel_keeps_failed_verification_out_of_cache(
|
||||
+ download_process: tuple[MagicMock, Wheel, bytearray],
|
||||
+ for_py_version: str,
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+) -> None:
|
||||
+ download_process[2].extend(b"\n")
|
||||
+ with pytest.raises(RuntimeError, match="but PyPI reports"):
|
||||
+ download_wheel("pip", None, for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ assert list(tmp_path.iterdir()) == []
|
||||
+
|
||||
+
|
||||
+def test_download_wheel_ignores_forged_saved_output(download_process, for_py_version, session_app_data, tmp_path) -> None:
|
||||
+ process, genuine, payload = download_process
|
||||
+ process.return_value.communicate.return_value = (f"Saved {genuine.path}", "")
|
||||
+ payload.extend(b"\n")
|
||||
+ with pytest.raises(RuntimeError, match="but PyPI reports"):
|
||||
+ download_wheel("pip", None, for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ assert list(tmp_path.iterdir()) == []
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize("invalid", ["extra", "wrong-project", "not-wheel", "symlink"])
|
||||
+def test_download_wheel_rejects_unexpected_staging(download_process, for_py_version, session_app_data, tmp_path, invalid):
|
||||
+ process, wheel, _payload = download_process
|
||||
+ original = process.side_effect
|
||||
+
|
||||
+ def downloaded(command, **kwargs):
|
||||
+ result = original(command, **kwargs)
|
||||
+ staging = Path(command[command.index("-d") + 1])
|
||||
+ actual = staging / wheel.name
|
||||
+ if invalid == "extra":
|
||||
+ (staging / "extra.whl").write_bytes(b"extra")
|
||||
+ elif invalid == "wrong-project":
|
||||
+ actual.rename(staging / wheel.name.replace("pip-", "other-", 1))
|
||||
+ elif invalid == "not-wheel":
|
||||
+ actual.rename(staging / "not-a-wheel.zip")
|
||||
+ else:
|
||||
+ actual.unlink()
|
||||
+ actual.symlink_to(wheel.path)
|
||||
+ return result
|
||||
+
|
||||
+ process.side_effect = downloaded
|
||||
+ with pytest.raises(RuntimeError, match="could not find downloaded wheel|unexpected downloaded wheel"):
|
||||
+ download_wheel("pip", None, for_py_version, [], session_app_data, tmp_path, {})
|
||||
+ assert list(tmp_path.iterdir()) == []
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize(
|
||||
+ "env_var",
|
||||
+ [
|
||||
+ pytest.param(name, id=name.lower())
|
||||
+ for name in (
|
||||
+ "PIP_INDEX_URL",
|
||||
+ "PIP_EXTRA_INDEX_URL",
|
||||
+ "PIP_INDEX",
|
||||
+ )
|
||||
+ ],
|
||||
+)
|
||||
+def test_download_wheel_preserves_custom_index_policy(
|
||||
+ download_process: tuple[MagicMock, Wheel, bytearray],
|
||||
+ for_py_version: str,
|
||||
+ session_app_data: AppDataDiskFolder,
|
||||
+ tmp_path: Path,
|
||||
+ env_var: str,
|
||||
+) -> None:
|
||||
+ download_process[2].extend(b"\n")
|
||||
+ result: Final = download_wheel(
|
||||
+ "pip",
|
||||
+ None,
|
||||
+ for_py_version,
|
||||
+ [],
|
||||
+ session_app_data,
|
||||
+ tmp_path,
|
||||
+ {env_var: "https://index.example/simple"},
|
||||
)
|
||||
- env = os.environ.copy()
|
||||
- env[env_var] = "https://example.com/simple"
|
||||
+ assert result.path.read_bytes() == bytes(download_process[2])
|
||||
|
||||
- result = download_wheel("pip", "==1", for_py_version, [], session_app_data, "folder", env)
|
||||
|
||||
- assert result.name == "a-b-c.whl"
|
||||
- assert verify.call_count == 0
|
||||
+@pytest.fixture
|
||||
+def download_process(mocker: MockerFixture, for_py_version: str) -> tuple[MagicMock, Wheel, bytearray]:
|
||||
+ wheel: Final = get_embed_wheel("pip", for_py_version)
|
||||
+ payload: Final = bytearray(wheel.path.read_bytes())
|
||||
+ # PyPy can retain the hash input buffer until garbage collection.
|
||||
+ metadata: Final = json.dumps({
|
||||
+ "releases": {
|
||||
+ wheel.version: [
|
||||
+ {
|
||||
+ "filename": wheel.name,
|
||||
+ "digests": {"sha256": hashlib.sha256(bytes(payload)).hexdigest()},
|
||||
+ }
|
||||
+ ]
|
||||
+ }
|
||||
+ }).encode()
|
||||
+
|
||||
+ def metadata_response(*_args: str, **_kwargs: SSLContext | None) -> BytesIO:
|
||||
+ return BytesIO(metadata)
|
||||
+
|
||||
+ mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", autospec=True, side_effect=metadata_response)
|
||||
+
|
||||
+ def downloaded(cmd: list[str], **_kwargs: str | int | bool | dict[str, str]) -> Popen[str]:
|
||||
+ (Path(cmd[cmd.index("-d") + 1]) / wheel.name).write_bytes(payload)
|
||||
+ return process.return_value
|
||||
+
|
||||
+ process: Final = mocker.patch("virtualenv.seed.wheels.acquire.Popen", autospec=True, side_effect=downloaded)
|
||||
+ process.return_value.configure_mock(**{"communicate.return_value": ("progress complete", ""), "returncode": 0})
|
||||
+ return process, wheel, payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
--
|
||||
2.35.6
|
||||
@@ -10,6 +10,10 @@ SRC_URI[sha256sum] = "b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b5
|
||||
|
||||
SRC_URI += " \
|
||||
file://CVE-2026-102938.patch \
|
||||
file://CVE-2026-102930-dependent.patch \
|
||||
file://CVE-2026-102930_p1.patch \
|
||||
file://CVE-2026-102930_p2.patch \
|
||||
file://CVE-2026-102930_p3.patch \
|
||||
"
|
||||
|
||||
BBCLASSEXTEND = "native nativesdk"
|
||||
@@ -26,6 +30,7 @@ RDEPENDS:${PN} += " \
|
||||
python3-json \
|
||||
python3-misc \
|
||||
python3-modules \
|
||||
python3-packaging \
|
||||
python3-platformdirs \
|
||||
"
|
||||
|
||||
|
||||
Reference in new issue
Block a user