diff --git a/SPECS/python-virtualenv/CVE-2026-102938.patch b/SPECS/python-virtualenv/CVE-2026-102938.patch new file mode 100644 index 00000000000..465ffa41874 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-102938.patch @@ -0,0 +1,90 @@ +From a30f995461043acb6cacbf3a890951563ccf7140 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Thu, 17 Sep 2026 18:06:12 +0200 +Subject: [PATCH] fix(create): keep configuration values on one line +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/a30f995461043acb6cacbf3a890951563ccf7140.patch + +Backport-note: Reuse the shared line-boundary sanitizer and retain exclusive +file writes. Keep only the writer fix and ordinary pytest unit coverage; do +not add optional dependency extras, property/fuzz infrastructure, or pytest +gating. +--- + src/virtualenv/create/pyenv_cfg.py | 3 ++- + tests/unit/create/test_pyenv_cfg.py | 42 +++++++++++++++++++++++++++++++++++ + 2 files changed, 44 insertions(+), 1 deletion(-) + +diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py +index 4ac788d..4645f94 100644 +--- a/src/virtualenv/create/pyenv_cfg.py ++++ b/src/virtualenv/create/pyenv_cfg.py +@@ -5,6 +5,7 @@ + from collections import OrderedDict + + from virtualenv.util.path import write_file ++from virtualenv.util.text import collapse_line_boundaries + + LOGGER = logging.getLogger(__name__) + +@@ -38,7 +39,7 @@ + text = "" + for key, value in self.content.items(): + normalized_value = os.path.realpath(value) if value and os.path.exists(value) else value +- line = f"{key} = {normalized_value}" ++ line = collapse_line_boundaries(f"{key} = {normalized_value}") + LOGGER.debug("\t%s", line) + text += line + text += "\n" +diff --git a/tests/unit/create/test_pyenv_cfg.py b/tests/unit/create/test_pyenv_cfg.py +new file mode 100644 +index 0000000..932a5d1 +--- /dev/null ++++ b/tests/unit/create/test_pyenv_cfg.py +@@ -0,0 +1,42 @@ ++from __future__ import annotations ++ ++from collections import OrderedDict ++ ++import pytest ++ ++from virtualenv.create.pyenv_cfg import PyEnvCfg ++ ++ ++@pytest.mark.parametrize("boundary", ["\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"]) ++@pytest.mark.parametrize("field", ["key", "value"]) ++def test_write_keeps_configuration_on_one_line_per_key(tmp_path, boundary, field): ++ path = tmp_path / "pyvenv.cfg" ++ home = str(tmp_path / "trusted") ++ key = f"prompt{boundary}suffix" if field == "key" else "prompt" ++ value = f"before{boundary}home = /untrusted" if field == "value" else "literal" ++ content = OrderedDict([("home", home), (key, value)]) ++ ++ PyEnvCfg(content, path).write() ++ ++ assert len(path.read_text(encoding="utf-8").splitlines()) == len(content) ++ assert PyEnvCfg.from_file(path).content == OrderedDict([ ++ ("home", home), ++ (key.replace(boundary, " "), value.replace(boundary, " ")), ++ ]) ++ ++ ++@pytest.mark.parametrize( ++ ("value", "expected"), ++ [ ++ pytest.param("plain", "plain", id="plain"), ++ pytest.param("a'\"$HOME\\b", "a'\"$HOME\\b", id="special-characters"), ++ pytest.param("a\r\nhome = /untrusted", "a home = /untrusted", id="crlf"), ++ pytest.param("", "", id="empty"), ++ ], ++) ++def test_configuration_value_round_trip(tmp_path, value, expected): ++ path = tmp_path / "pyvenv.cfg" ++ ++ PyEnvCfg(OrderedDict([("prompt", value)]), path).write() ++ ++ assert PyEnvCfg.from_file(path).content == OrderedDict([("prompt", expected)]) +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113713.patch b/SPECS/python-virtualenv/CVE-2026-113713.patch new file mode 100644 index 00000000000..1254853a159 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113713.patch @@ -0,0 +1,109 @@ +From b81995c3feab9e1edb453c75ffc6616e1a77335e Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 10:09:55 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(seed):=20copy=20from=20tempo?= + =?UTF-8?q?rary=20app-data=20(#3437)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/b81995c3feab9e1edb453c75ffc6616e1a77335e.patch +--- + docs/changelog/3437.bugfix.rst | 2 + + .../seed/embed/via_app_data/via_app_data.py | 4 +- + .../embed/test_bootstrap_link_via_app_data.py | 43 ++++++++++++++++++- + 3 files changed, 46 insertions(+), 3 deletions(-) + create mode 100644 docs/changelog/3437.bugfix.rst + +diff --git a/docs/changelog/3437.bugfix.rst b/docs/changelog/3437.bugfix.rst +new file mode 100644 +index 0000000..0345827 +--- /dev/null ++++ b/docs/changelog/3437.bugfix.rst +@@ -0,0 +1,2 @@ ++Always copy seed packages when app-data is temporary, including when symlink seeding is enabled through an environment ++variable or configuration file. Seeded packages remain available after temporary cache cleanup. +diff --git a/src/virtualenv/seed/embed/via_app_data/via_app_data.py b/src/virtualenv/seed/embed/via_app_data/via_app_data.py +index a2e9630..0aada69 100644 +--- a/src/virtualenv/seed/embed/via_app_data/via_app_data.py ++++ b/src/virtualenv/seed/embed/via_app_data/via_app_data.py +@@ -23,7 +23,9 @@ LOGGER = logging.getLogger(__name__) + class FromAppData(BaseEmbed): + def __init__(self, options) -> None: + super().__init__(options) +- self.symlinks = options.symlink_app_data ++ self.symlinks = options.symlink_app_data and not self.app_data.transient ++ if options.symlink_app_data and self.app_data.transient: ++ LOGGER.debug("copy seed packages because temporary app-data is removed after creation") + + @classmethod + def add_parser_arguments(cls, parser, interpreter, app_data): +diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +index 4076573..d59288a 100644 +--- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py ++++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +@@ -1,8 +1,10 @@ + from __future__ import annotations + + import contextlib ++import logging + import os + import sys ++from pathlib import Path + from stat import S_IWGRP, S_IWOTH, S_IWUSR + from subprocess import Popen, check_call + from threading import Thread +@@ -18,11 +20,48 @@ from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SUPPORT + from virtualenv.util.path import safe_delete + + if TYPE_CHECKING: +- from pathlib import Path +- + from pytest_mock import MockerFixture + + ++@pytest.mark.parametrize("source", ["environment", "configuration", "command-line"]) ++def test_transient_seed_survives_cache_cleanup(tmp_path, source, unusable_cache): ++ config = tmp_path / "virtualenv.ini" ++ config.write_text("[virtualenv]\nsymlink_app_data = true\n" if source == "configuration" else "", encoding="utf-8") ++ env = { ++ **os.environ, ++ "VIRTUALENV_CONFIG_FILE": str(config), ++ "VIRTUALENV_OVERRIDE_APP_DATA": str(unusable_cache), ++ } ++ if source == "environment": ++ env["VIRTUALENV_SYMLINK_APP_DATA"] = "true" ++ args = [str(tmp_path / "env"), "--no-periodic-update", "--no-setuptools"] ++ if source == "command-line": ++ args.append("--symlink-app-data") ++ session = cli_run(args, env=env) ++ assert ( ++ session.creator.app_data.transient, ++ Path(str(session.creator.app_data)).exists(), ++ (session.creator.purelib / "pip" / "__init__.py").is_file(), ++ ) == (True, False, True) ++ ++ ++def test_transient_seed_reports_copy_fallback(tmp_path, caplog, unusable_cache): ++ caplog.set_level(logging.DEBUG) ++ cli_run( ++ [str(tmp_path / "env"), "--no-periodic-update", "--no-setuptools"], ++ env={**os.environ, "VIRTUALENV_OVERRIDE_APP_DATA": str(unusable_cache), "VIRTUALENV_SYMLINK_APP_DATA": "true"}, ++ setup_logging=False, ++ ) ++ assert "copy seed packages because temporary app-data is removed after creation" in caplog.text ++ ++ ++@pytest.fixture ++def unusable_cache(tmp_path): ++ blocked = tmp_path / "blocked" ++ blocked.touch() ++ return blocked / "cache" ++ ++ + @pytest.mark.slow + @pytest.mark.parametrize("copies", [False, True] if fs_supports_symlink() else [True]) + def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies, for_py_version): # noqa: PLR0915 +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113715.patch b/SPECS/python-virtualenv/CVE-2026-113715.patch new file mode 100644 index 00000000000..4c4f4d5f768 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113715.patch @@ -0,0 +1,192 @@ +From b0008a58885ded9227faf8f910c6c402712c750a Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 02:56:02 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(create):=20reject=20ambiguou?= + =?UTF-8?q?s=20venv=20paths=20(#3426)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference:https://github.com/pypa/virtualenv/commit/b0008a58885ded9227faf8f910c6c402712c750a.patch +--- + docs/changelog/3426.bugfix.rst | 4 ++ + src/virtualenv/create/via_global_ref/venv.py | 22 ++++++-- + src/virtualenv/util/text.py | 29 +++++++++++ + tests/unit/create/test_creator.py | 53 ++++++++++++++++++++ + 4 files changed, 104 insertions(+), 4 deletions(-) + create mode 100644 docs/changelog/3426.bugfix.rst + create mode 100644 src/virtualenv/util/text.py + +diff --git a/docs/changelog/3426.bugfix.rst b/docs/changelog/3426.bugfix.rst +new file mode 100644 +index 0000000..a5a5700 +--- /dev/null ++++ b/docs/changelog/3426.bugfix.rst +@@ -0,0 +1,4 @@ ++Reject destinations containing line boundaries when using the stdlib ``venv`` creator, before it writes ``pyvenv.cfg``. ++Other creators retain their existing destination support. Write the final configuration from the selected interpreter ++and virtualenv options instead of merging stdlib's intermediate configuration. GraalPy launcher settings are derived ++from the selected interpreter. +diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py +index 005d34f..e852b46 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -1,13 +1,14 @@ + from __future__ import annotations + + import logging +-from copy import copy ++from argparse import ArgumentTypeError + + from virtualenv.create.via_global_ref.store import handle_store_python + from virtualenv.discovery.py_info import PythonInfo + from virtualenv.util.error import ProcessCallFailedError + from virtualenv.util.path import ensure_within + from virtualenv.util.subprocess import run_cmd ++from virtualenv.util.text import collapse_line_boundaries + + from .api import ViaGlobalRefApi, ViaGlobalRefMeta + from .builtin.cpython.mac_os import CPython3macOsBrew +@@ -48,6 +49,18 @@ class Venv(ViaGlobalRefApi): + super().create() + self.executables_for_win_pypy_less_v37() + ++ @classmethod ++ def validate_dest(cls, raw_value): ++ return cls._check_line_boundaries(super().validate_dest(cls._check_line_boundaries(raw_value))) ++ ++ @staticmethod ++ def _check_line_boundaries(value): ++ # stdlib venv writes the destination into pyvenv.cfg without escaping line boundaries. ++ if collapse_line_boundaries(value) != value: ++ msg = f"destination {value!r} must not contain a line boundary as venv cannot record it in pyvenv.cfg" ++ raise ArgumentTypeError(msg) ++ return value ++ + def executables_for_win_pypy_less_v37(self): + """ + PyPy <= 3.6 (v7.3.3) for Windows contains only pypy3.exe and pypy3w.exe +@@ -85,10 +98,11 @@ class Venv(ViaGlobalRefApi): + return cmd + + def set_pyenv_cfg(self): +- # prefer venv options over ours, but keep our extra +- venv_content = copy(self.pyenv_cfg.refresh()) + super().set_pyenv_cfg() +- self.pyenv_cfg.update(venv_content) ++ if self.interpreter.implementation == "GraalVM": ++ # GraalPy launchers need these settings on Windows and macOS. ++ self.pyenv_cfg["venvlauncher_command"] = self.interpreter.system_executable ++ self.pyenv_cfg["base-executable"] = self.interpreter.system_executable + + def __getattribute__(self, item): + describe = object.__getattribute__(self, "describe") +diff --git a/src/virtualenv/util/text.py b/src/virtualenv/util/text.py +new file mode 100644 +index 0000000..ff8997b +--- /dev/null ++++ b/src/virtualenv/util/text.py +@@ -0,0 +1,29 @@ ++"""Shared text-safety helpers for values written into line-based formats.""" ++ ++from __future__ import annotations ++ ++# These are the boundaries recognized by str.splitlines(). ++_LINE_BOUNDARIES = ( ++ "\n", ++ "\r", ++ "\v", ++ "\f", ++ "\x1c", ++ "\x1d", ++ "\x1e", ++ "\x85", ++ chr(0x2028), ++ chr(0x2029), ++) ++ ++ ++def collapse_line_boundaries(text): ++ """Replace every line-boundary character with a space so text cannot become extra lines.""" ++ for boundary in _LINE_BOUNDARIES: ++ text = text.replace(boundary, " ") ++ return text ++ ++ ++__all__ = [ ++ "collapse_line_boundaries", ++] +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index e22f028..0504319 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -96,6 +96,40 @@ def system(session_app_data): + CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] + + ++@pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") ++@pytest.mark.parametrize( ++ "boundary", ++ [ ++ pytest.param(char, id=name) ++ for name, char in ( ++ ("newline", "\n"), ++ ("return", "\r"), ++ ("vertical-tab", "\v"), ++ ("form-feed", "\f"), ++ ("file-separator", "\x1c"), ++ ("group-separator", "\x1d"), ++ ("record-separator", "\x1e"), ++ ("next-line", "\x85"), ++ ("line-separator", "\u2028"), ++ ("paragraph-separator", "\u2029"), ++ ) ++ ], ++) ++def test_venv_creator_rejects_line_boundary(tmp_path, capsys, boundary): ++ destination = tmp_path / f"project{boundary}name" ++ with pytest.raises(SystemExit) as context: ++ cli_run([str(destination), "--creator", "venv", "--no-seed"]) ++ assert context.value.code == 2 ++ assert "must not contain a line boundary" in capsys.readouterr().err ++ assert not list(tmp_path.iterdir()) ++ ++ ++@pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") ++def test_venv_creator_accepts_plain_destination(tmp_path): ++ session = cli_run([str(tmp_path / "environment"), "--creator", "venv", "--no-seed"]) ++ assert (session.creator.dest / "pyvenv.cfg").is_file() ++ ++ + @pytest.mark.parametrize( + "junction", + [ +@@ -321,6 +355,25 @@ def test_create_vcs_ignore_exists_override(tmp_path): + assert git_ignore.read_text(encoding="utf-8") == "magic" + + ++@pytest.mark.skipif(not CURRENT.has_venv, reason="requires venv") ++@pytest.mark.parametrize("global_packages", [pytest.param(False, id="isolated"), pytest.param(True, id="global")]) ++def test_venv_configuration_uses_selected_options(tmp_path, global_packages): ++ args = [str(tmp_path / "env"), "--creator", "venv", "--no-seed"] ++ if global_packages: ++ args.append("--system-site-packages") ++ session = cli_run(args) ++ session.creator.pyenv_cfg.path.write_text("vendor-marker = unused\n", encoding="utf-8") ++ session.creator.set_pyenv_cfg() ++ executable = session.creator.interpreter.system_executable if CURRENT.implementation == "GraalVM" else None ++ expected = { ++ "vendor-marker": None, ++ "include-system-site-packages": str(global_packages).lower(), ++ "venvlauncher_command": executable, ++ "base-executable": executable, ++ } ++ assert {key: session.creator.pyenv_cfg.content.get(key) for key in expected} == expected ++ ++ + @pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv") + def test_venv_fails_not_inline(tmp_path, capsys, mocker): + if hasattr(os, "geteuid") and os.geteuid() == 0: +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113716.patch b/SPECS/python-virtualenv/CVE-2026-113716.patch new file mode 100644 index 00000000000..f5ba9b528f2 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113716.patch @@ -0,0 +1,334 @@ +From 554d9f9d4f7758f5cf53f993031e29787f1f74c5 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:36:11 -0700 +Subject: [PATCH] fix(create): replace environment directory links +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/554d9f9d4f7758f5cf53f993031e29787f1f74c5.patch + +Backport-note: Include the shared directory-containment helper required by the +older creators. The generated-file protection is applied separately afterward. +--- + docs/changelog/3429.bugfix.rst | 3 + + .../via_global_ref/builtin/via_global_self_do.py | 4 - + src/virtualenv/create/via_global_ref/venv.py | 6 + + src/virtualenv/util/path/__init__.py | 3 - + src/virtualenv/util/path/_sync.py | 37 +++++++++ + tests/unit/create/test_creator.py | 51 ++++++++++++ + tests/unit/test_util.py | 83 ++++++++++++++++++++ + 7 files changed, 179 insertions(+), 8 deletions(-) + +diff --git a/docs/changelog/3429.bugfix.rst b/docs/changelog/3429.bugfix.rst +new file mode 100644 +index 0000000..0f7596e +--- /dev/null ++++ b/docs/changelog/3429.bugfix.rst +@@ -0,0 +1,3 @@ ++Replace pre-existing directory symlinks and Windows junctions in the environment's executable and library paths before ++creating files. Links within those paths are replaced even when they point inside the environment. Log replaced links ++and junctions, and reject a non-directory encountered while creating their replacements. +diff --git a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py +index 2f7f2f1..7c05ec5 100644 +--- a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py ++++ b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py +@@ -8,7 +8,7 @@ + RefMust, + RefWhen, + ) +-from virtualenv.util.path import ensure_dir ++from virtualenv.util.path import ensure_within + + from .builtin_way import VirtualenvBuiltin + +@@ -79,7 +79,7 @@ + if any(i for i in dirs if i is not directory and directory.parts == i.parts[: len(directory.parts)]): + dirs.remove(directory) + for directory in sorted(dirs): +- ensure_dir(directory) ++ ensure_within(self.dest, directory) + + self.set_pyenv_cfg() + self.pyenv_cfg.write() +diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py +index d5037bc..005d34f 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -6,7 +6,7 @@ + from virtualenv.create.via_global_ref.store import handle_store_python + from virtualenv.discovery.py_info import PythonInfo + from virtualenv.util.error import ProcessCallFailedError +-from virtualenv.util.path import ensure_dir ++from virtualenv.util.path import ensure_within + from virtualenv.util.subprocess import run_cmd + + from .api import ViaGlobalRefApi, ViaGlobalRefMeta +@@ -39,12 +39,12 @@ + return None + + def create(self): ++ for directory in (self.bin_dir, self.purelib, self.platlib): ++ ensure_within(self.dest, directory) + if self.can_be_inline: + self.create_inline() + else: + self.create_via_sub_process() +- for lib in self.libs: +- ensure_dir(lib) + super().create() + self.executables_for_win_pypy_less_v37() + +diff --git a/src/virtualenv/util/path/__init__.py b/src/virtualenv/util/path/__init__.py +index c144d8d..f2cccc8 100644 +--- a/src/virtualenv/util/path/__init__.py ++++ b/src/virtualenv/util/path/__init__.py +@@ -1,13 +1,14 @@ + from __future__ import annotations + + from ._permission import make_exe, set_tree +-from ._sync import copy, copytree, ensure_dir, safe_delete, symlink ++from ._sync import copy, copytree, ensure_dir, ensure_within, safe_delete, symlink + from ._win import get_short_path_name + + __all__ = [ + "copy", + "copytree", + "ensure_dir", ++ "ensure_within", + "get_short_path_name", + "make_exe", + "safe_delete", +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index 4dd5a98..80c1c20 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -4,7 +4,10 @@ + import os + import shutil + import sys +-from stat import S_IWUSR ++from stat import S_ISDIR, S_ISLNK, S_IWUSR ++ ++if os.name == "nt": ++ from stat import IO_REPARSE_TAG_MOUNT_POINT + + LOGGER = logging.getLogger(__name__) + +@@ -13,6 +16,37 @@ + if not path.exists(): + LOGGER.debug("create folder %s", path) + os.makedirs(str(path)) ++ ++ ++def ensure_within(root, path): ++ parts = path.relative_to(root).parts ++ if ".." in parts: ++ msg = f"environment directory {path} must remain inside {root}" ++ raise ValueError(msg) ++ ensure_dir(root) ++ folder = root ++ for part in parts: ++ folder /= part ++ try: ++ metadata = folder.lstat() ++ except FileNotFoundError: ++ pass ++ else: ++ if S_ISLNK(metadata.st_mode): ++ LOGGER.warning("replace environment directory link %s", folder) ++ folder.unlink() ++ elif os.name == "nt" and metadata.st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT: ++ LOGGER.warning("replace environment directory junction %s", folder) ++ folder.rmdir() ++ try: ++ folder.mkdir() ++ except FileExistsError: ++ created = folder.lstat() ++ if not S_ISDIR(created.st_mode) or ( ++ os.name == "nt" and created.st_reparse_tag == IO_REPARSE_TAG_MOUNT_POINT ++ ): ++ msg = f"environment directory is not a real directory: {folder}" ++ raise NotADirectoryError(msg) from None + + + def ensure_safe_to_do(src, dest): +@@ -79,6 +113,7 @@ + "copy", + "copytree", + "ensure_dir", ++ "ensure_within", + "safe_delete", + "symlink", + "symlink", +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index c72e830..e22f028 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -29,7 +29,7 @@ + from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew + from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix + from virtualenv.discovery.py_info import PythonInfo +-from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive ++from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink + from virtualenv.run import cli_run, session_via_cli + + CURRENT = PythonInfo.current_system() +@@ -94,6 +94,55 @@ + + + CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] ++ ++ ++@pytest.mark.parametrize( ++ "junction", ++ [ ++ pytest.param( ++ False, id="symlink", marks=pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++ ), ++ pytest.param(True, id="junction", marks=pytest.mark.skipif(os.name != "nt", reason="Windows junctions")), ++ ], ++) ++@pytest.mark.parametrize( ++ "creator_name", ++ [ ++ "builtin", ++ pytest.param( ++ "venv", marks=pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") ++ ), ++ ], ++) ++@pytest.mark.parametrize("folder", ["bin_dir", "purelib", "platlib"]) ++@pytest.mark.parametrize("inside", [False, True], ids=["outside", "inside"]) ++def test_creator_replaces_directory_links(tmp_path, caplog, creator_name, folder, junction, inside): # noqa: PLR0913 ++ session = session_via_cli([str(tmp_path / "env"), "--creator", creator_name, "--no-seed"], setup_logging=False) ++ creator = session.creator ++ creator.dest.mkdir() ++ stored = (creator.dest if inside else tmp_path) / "stored" ++ stored.mkdir() ++ marker = stored / "keep.txt" ++ marker.write_text("preserve", encoding="utf-8") ++ link = creator.dest / getattr(creator, folder).relative_to(creator.dest).parts[0] ++ if junction: ++ cmd = shutil.which("cmd") ++ assert cmd is not None ++ subprocess.run([cmd, "/c", "mklink", "/J", str(link), str(stored)], check=True, capture_output=True) ++ else: ++ link.symlink_to(stored, target_is_directory=True) ++ ++ with session: ++ session.run() ++ ++ assert link.resolve() == link ++ assert creator.exe.is_file() ++ assert list(stored.iterdir()) == [marker] ++ assert marker.read_text(encoding="utf-8") == "preserve" ++ kind = "junction" if junction else "link" ++ assert f"replace environment directory {kind} {link}" in caplog.text ++ ++ + CREATE_METHODS = [] + for k, v in CURRENT.creators().key_to_meta.items(): + if k in CURRENT_CREATORS: +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index b83c247..10ed7e7 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -1,12 +1,95 @@ + from __future__ import annotations + + import concurrent.futures ++import os ++import shutil ++import subprocess + import traceback ++from pathlib import Path + + import pytest + ++from virtualenv.info import fs_supports_symlink + from virtualenv.util.lock import ReentrantFileLock ++from virtualenv.util.path import ensure_within + from virtualenv.util.subprocess import run_cmd ++ ++ ++@pytest.mark.parametrize("existing", [False, True]) ++def test_environment_directories_create_nested_path(tmp_path, existing): ++ folder = tmp_path / "env" / "lib" / "python" ++ if existing: ++ folder.mkdir(parents=True) ++ ensure_within(tmp_path / "env", folder) ++ assert folder.is_dir() ++ ++ ++def test_environment_directories_reject_regular_file(tmp_path): ++ folder = tmp_path / "file" ++ folder.touch() ++ with pytest.raises(NotADirectoryError, match="not a real directory"): ++ ensure_within(tmp_path, folder) ++ assert folder.is_file() ++ ++ ++def test_environment_directories_reject_parent_components(tmp_path): ++ with pytest.raises(ValueError, match="must remain inside"): ++ ensure_within(tmp_path, tmp_path / ".." / "folder") ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++@pytest.mark.parametrize("existing", [False, True]) ++def test_environment_directories_remove_links(tmp_path, caplog, existing): ++ target = tmp_path / "stored" ++ if existing: ++ target.mkdir() ++ link = tmp_path / "link" ++ link.symlink_to(target, target_is_directory=True) ++ ++ ensure_within(tmp_path, link / "nested") ++ ++ assert not link.is_symlink() ++ assert (link / "nested").is_dir() ++ assert target.exists() is existing ++ if existing: ++ assert not list(target.iterdir()) ++ assert f"replace environment directory link {link}" in caplog.text ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++def test_environment_directories_reject_replaced_link(tmp_path, monkeypatch): ++ target = tmp_path / "stored" ++ target.mkdir() ++ link = tmp_path / "link" ++ link.symlink_to(target, target_is_directory=True) ++ mkdir = Path.mkdir ++ ++ def replace_link(path, *args, **kwargs): ++ if path == link: ++ path.symlink_to(target, target_is_directory=True) ++ return mkdir(path, *args, **kwargs) ++ ++ monkeypatch.setattr(Path, "mkdir", replace_link) ++ ++ with pytest.raises(NotADirectoryError, match="not a real directory"): ++ ensure_within(tmp_path, link) ++ assert not list(target.iterdir()) ++ ++ ++@pytest.mark.skipif(os.name != "nt", reason="Windows junctions") ++def test_environment_directories_report_replaced_junction(tmp_path, caplog): ++ target = tmp_path / "stored" ++ target.mkdir() ++ link = tmp_path / "link" ++ cmd = shutil.which("cmd") ++ assert cmd is not None ++ subprocess.run([cmd, "/c", "mklink", "/J", str(link), str(target)], check=True, capture_output=True) ++ ++ ensure_within(tmp_path, link) ++ ++ assert link.resolve() == link ++ assert not list(target.iterdir()) ++ assert f"replace environment directory junction {link}" in caplog.text + + + def test_run_fail(tmp_path): +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113719.patch b/SPECS/python-virtualenv/CVE-2026-113719.patch new file mode 100644 index 00000000000..83af93b77f7 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113719.patch @@ -0,0 +1,187 @@ +From e3cea6fa10a50fa29cf1fdddd772458fba076d5f Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:19:58 -0700 +Subject: [PATCH] fix(seed): contain metadata cleanup +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/e3cea6fa10a50fa29cf1fdddd772458fba076d5f.patch + +Backport-note: Adapt the metadata cleanup to the older seeder and preserve the +preceding console-launcher backport. +--- + docs/changelog/3428.bugfix.rst | 3 + + .../seed/embed/via_app_data/pip_install/base.py | 37 ++++++--- + .../seed/embed/test_bootstrap_link_via_app_data.py | 82 ++++++++++++++++++++ + 3 files changed, 110 insertions(+), 12 deletions(-) + +diff --git a/docs/changelog/3428.bugfix.rst b/docs/changelog/3428.bugfix.rst +new file mode 100644 +index 0000000..43c9058 +--- /dev/null ++++ b/docs/changelog/3428.bugfix.rst +@@ -0,0 +1,3 @@ ++Limit seed-package cleanup to entries inside the environment. Ignore metadata entries that name the environment itself, ++a directory holding the metadata (a blank ``top_level.txt`` line names ``site-packages``), or resolve outside the ++environment, with a warning. Launcher entries inside the environment remain supported. +diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +index 3823103..c734794 100644 +--- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py ++++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +@@ -149,35 +149,48 @@ + paths = (p for p in in_folders if p.stem.split("-")[0] == dist_name and p.suffix == ".dist-info" and p.is_dir()) + existing_dist = next(paths, None) + if existing_dist is not None: +- self._uninstall_dist(existing_dist) ++ self._uninstall_dist(existing_dist, self._creator.dest) + + @staticmethod +- def _uninstall_dist(dist): ++ def _uninstall_dist(dist, env_dir): + dist_base = dist.parent + LOGGER.debug("uninstall existing distribution %s from %s", dist.stem, dist_base) + +- top_txt = dist / "top_level.txt" # add top level packages at folder level ++ top_txt = dist / "top_level.txt" + paths = ( + {dist.parent / i.strip() for i in top_txt.read_text(encoding="utf-8").splitlines()} + if top_txt.exists() + else set() + ) +- paths.add(dist) # add the dist-info folder itself +- +- base_dirs, record = paths.copy(), dist / "RECORD" # collect entries in record that we did not register yet ++ paths.add(dist) ++ ++ base_dirs = paths.copy() ++ record = dist / "RECORD" + for name in ( + (i.split(",")[0] for i in record.read_text(encoding="utf-8").splitlines()) if record.exists() else () + ): + path = dist_base / name +- if not any(p in base_dirs for p in path.parents): # only add if not already added as a base dir ++ if not any(p in base_dirs for p in path.parents): + paths.add(path) + +- for path in sorted(paths): # actually remove stuff in a stable order +- if path.exists(): +- if path.is_dir() and not path.is_symlink(): +- safe_delete(path) ++ root = env_dir.resolve() ++ metadata_parents = set(dist.resolve().parents) ++ for entry in sorted(paths): ++ if entry.exists() or entry.is_symlink(): ++ linked = entry.is_symlink() ++ # Unlinking the final link leaves its target intact; parent links still affect containment. ++ resolved = entry.parent.resolve() / entry.name if linked else entry.resolve() ++ if root not in resolved.parents or resolved in metadata_parents: ++ LOGGER.warning( ++ "skip removal of %s listed by %s: not inside environment %s or a parent of the metadata", ++ entry, ++ dist.name, ++ root, ++ ) ++ elif entry.is_dir() and not linked: ++ safe_delete(entry) + else: +- path.unlink() ++ entry.unlink() + + def clear(self): + if self._image_dir.exists(): +diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +index ac62117..4b178dd 100644 +--- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py ++++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +@@ -24,6 +24,88 @@ + + if TYPE_CHECKING: + from pytest_mock import MockerFixture ++ ++ ++@pytest.mark.parametrize( ++ ("metadata_file", "content"), ++ [ ++ pytest.param("top_level.txt", "pip\n{dest}\n", id="top-level-env-root"), ++ pytest.param("top_level.txt", "pip\n\n", id="top-level-blank"), ++ pytest.param("RECORD", "{record}\n{outside},,", id="record-traversal"), ++ ], ++) ++def test_seed_metadata_cleanup_stays_inside_package(tmp_path, caplog, metadata_file, content): ++ args = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"] ++ session = cli_run(args, setup_logging=False) ++ purelib = session.creator.purelib ++ markers = [tmp_path / "keep.txt", session.creator.dest / "keep.txt", purelib / "keep.txt"] ++ for marker in markers: ++ marker.write_text("keep", encoding="utf-8") ++ dist_info = next(purelib.glob("pip-*.dist-info")) ++ (dist_info / metadata_file).write_text( ++ content.format( ++ dest=session.creator.dest, ++ outside=os.path.relpath(markers[0], purelib), ++ record=(dist_info / "RECORD").read_text(encoding="utf-8"), ++ ), ++ encoding="utf-8", ++ ) ++ cli_run(args, setup_logging=False) ++ assert ( ++ [i.exists() for i in markers], ++ (purelib / "pip" / "__init__.py").is_file(), ++ "skip removal of" in caplog.text, ++ ) == ( ++ [True, True, True], ++ True, ++ True, ++ ) ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++def test_seed_metadata_unlinks_symlink_without_following(tmp_path, caplog): ++ args = [str(tmp_path / "env"), "--pip", "embed", "--no-periodic-update"] ++ session = cli_run(args, setup_logging=False) ++ outside = tmp_path / "outside" ++ outside.mkdir() ++ (outside / "keep.txt").write_text("keep", encoding="utf-8") ++ link = session.creator.purelib / "linked" ++ link.symlink_to(outside, target_is_directory=True) ++ record = next(session.creator.purelib.glob("pip-*.dist-info")) / "RECORD" ++ record.write_text(f"{record.read_text(encoding='utf-8')}\nlinked,,", encoding="utf-8") ++ cli_run(args, setup_logging=False) ++ assert (link.is_symlink(), (outside / "keep.txt").exists(), "skip removal of" in caplog.text) == ( ++ False, ++ True, ++ False, ++ ) ++ ++ ++@pytest.mark.parametrize( ++ "linked", ++ [ ++ pytest.param(False, id="copy"), ++ pytest.param( ++ True, id="symlink", marks=pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++ ), ++ ], ++) ++def test_seed_metadata_keeps_reseeding_supported(tmp_path, linked, caplog): ++ args = [ ++ str(tmp_path / "env"), ++ "--pip", ++ "embed", ++ "--no-periodic-update", ++ *(["--symlink-app-data"] if linked else []), ++ ] ++ cli_run(args, setup_logging=False) ++ session = cli_run(args, setup_logging=False) ++ package = session.creator.purelib / "pip" ++ assert ((package / "__init__.py").is_file(), package.is_symlink(), "skip removal of" in caplog.text) == ( ++ True, ++ linked, ++ False, ++ ) + + + @pytest.mark.parametrize("source", ["environment", "configuration", "command-line"]) +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113720.patch b/SPECS/python-virtualenv/CVE-2026-113720.patch new file mode 100644 index 00000000000..6210d0d5664 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113720.patch @@ -0,0 +1,232 @@ +From 35f0b33562790176dd186d0a3c517af3acc7a0e1 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 02:57:36 -0700 +Subject: [PATCH] fix(copy): apply the umask and replace dangling links +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/35f0b33562790176dd186d0a3c517af3acc7a0e1.patch + +Backport-note: Also restore upstream's dangling-symlink destination cleanup +prerequisite before using exclusive file creation. Add file, directory, +copytree, and builtin-creator regression tests without following link targets. +--- + docs/changelog/3425.bugfix.rst | 4 ++ + src/virtualenv/util/path/_sync.py | 32 +++++++++----- + tests/unit/test_util.py | 82 +++++++++++++++++++++++++++++++++++++ + tests/unit/create/test_creator.py | 23 +++++++++- + 4 files changed, 126 insertions(+), 15 deletions(-) + +diff --git a/docs/changelog/3425.bugfix.rst b/docs/changelog/3425.bugfix.rst +new file mode 100644 +index 0000000..ff158d1 +--- /dev/null ++++ b/docs/changelog/3425.bugfix.rst +@@ -0,0 +1,4 @@ ++Apply the current umask when copying interpreter and seed-package files instead of inheriting cached permission bits. ++Executable sources remain executable within the permissions allowed by that umask. Copied files no longer grant group or ++world write access, even with a permissive umask. Source modes are not preserved: data files start from 0644 and ++executable files from 0755 before applying the umask. +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index 80c1c20..f44a589 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -4,7 +4,8 @@ + import os + import shutil + import sys +-from stat import S_ISDIR, S_ISLNK, S_IWUSR ++from pathlib import Path ++from stat import S_ISDIR, S_ISLNK, S_IWUSR, S_IXGRP, S_IXOTH, S_IXUSR + + if os.name == "nt": + from stat import IO_REPARSE_TAG_MOUNT_POINT +@@ -53,7 +54,7 @@ + if src == dest: + msg = f"source and destination is the same {src}" + raise ValueError(msg) +- if not dest.exists(): ++ if not dest.exists() and not dest.is_symlink(): + return + if dest.is_dir() and not dest.is_symlink(): + LOGGER.debug("remove directory %s", dest) +@@ -70,22 +71,29 @@ + + + def copy(src, dest): +- ensure_safe_to_do(src, dest) +- is_dir = src.is_dir() +- method = copytree if is_dir else shutil.copy + LOGGER.debug("copy %s", _Debug(src, dest)) +- method(str(src), str(dest)) ++ if src.is_dir(): ++ ensure_safe_to_do(src, dest) ++ copytree(str(src), str(dest)) ++ else: ++ _copy_file(src, dest) + + + def copytree(src, dest): + for root, _, files in os.walk(src): +- dest_dir = os.path.join(dest, os.path.relpath(root, src)) +- if not os.path.isdir(dest_dir): +- os.makedirs(dest_dir) ++ dest_dir = Path(dest) / os.path.relpath(root, src) ++ dest_dir.mkdir(parents=True, exist_ok=True) + for name in files: +- src_f = os.path.join(root, name) +- dest_f = os.path.join(dest_dir, name) +- shutil.copy(src_f, dest_f) ++ _copy_file(Path(root) / name, dest_dir / name) ++ ++ ++def _copy_file(src, dest): ++ ensure_safe_to_do(src, dest) ++ mode = 0o755 if src.stat().st_mode & (S_IXUSR | S_IXGRP | S_IXOTH) else 0o644 ++ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | (os.O_BINARY if os.name == "nt" else 0) ++ # Creation applies the current umask without changing the process-wide setting. ++ with src.open("rb") as source, os.fdopen(os.open(str(dest), flags, mode), "wb") as target: ++ shutil.copyfileobj(source, target, length=1024 * 1024) + + + def safe_delete(dest): +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index 10ed7e7..fce3f18 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -11,8 +11,88 @@ + + from virtualenv.info import fs_supports_symlink + from virtualenv.util.lock import ReentrantFileLock +-from virtualenv.util.path import ensure_within ++from virtualenv.util.path import copy, copytree, ensure_within + from virtualenv.util.subprocess import run_cmd ++ ++ ++@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") ++@pytest.mark.parametrize("directory", [pytest.param(False, id="file"), pytest.param(True, id="tree")]) ++@pytest.mark.parametrize( ++ ("source_mode", "mask", "expected"), ++ [ ++ pytest.param(0o666, 0o022, 0o644, id="normal"), ++ pytest.param(0o666, 0o077, 0o600, id="private"), ++ pytest.param(0o444, 0o022, 0o644, id="read-only-source"), ++ pytest.param(0o600, 0o022, 0o644, id="private-source"), ++ pytest.param(0o777, 0o022, 0o755, id="executable"), ++ pytest.param(0o777, 0o077, 0o700, id="private-executable"), ++ pytest.param(0o666, 0o000, 0o644, id="permissive-umask"), ++ pytest.param(0o777, 0o000, 0o755, id="permissive-executable"), ++ pytest.param(0o666, 0o002, 0o644, id="group-umask"), ++ pytest.param(0o777, 0o002, 0o755, id="group-executable"), ++ ], ++) ++def test_copy_uses_current_umask(tmp_path, directory, source_mode, mask, expected): ++ source = tmp_path / "source" ++ source.mkdir() ++ payload = source / "entry" ++ payload.write_bytes(b"copied bytes") ++ payload.chmod(source_mode) ++ destination = tmp_path / "destination" ++ previous = os.umask(mask) ++ try: ++ copy(source if directory else payload, destination) ++ finally: ++ os.umask(previous) ++ result = destination / "entry" if directory else destination ++ assert (result.read_bytes(), result.stat().st_mode & 0o777) == (b"copied bytes", expected) ++ ++ ++@pytest.mark.parametrize("directory", [pytest.param(False, id="file"), pytest.param(True, id="tree")]) ++def test_copy_replaces_contents(tmp_path, directory): ++ source = tmp_path / "source" ++ source.mkdir() ++ (source / "entry").write_bytes(b"new") ++ destination = tmp_path / "destination" ++ destination.write_bytes(b"old") ++ copy(source if directory else source / "entry", destination) ++ assert (destination / "entry" if directory else destination).read_bytes() == b"new" ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++@pytest.mark.parametrize("directory", [pytest.param(False, id="file"), pytest.param(True, id="tree")]) ++def test_copy_replaces_dangling_destination_link(tmp_path, directory): ++ source = tmp_path / "source" ++ source.mkdir() ++ payload = source / "entry" ++ payload.write_bytes(b"new") ++ target = tmp_path / "missing" ++ destination = tmp_path / "destination" ++ destination.symlink_to(target, target_is_directory=directory) ++ ++ copy(source if directory else payload, destination) ++ ++ assert not destination.is_symlink() ++ assert (destination / "entry" if directory else destination).read_bytes() == b"new" ++ assert not target.exists() ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++def test_copytree_replaces_dangling_file_link(tmp_path): ++ source = tmp_path / "source" ++ source.mkdir() ++ (source / "entry").write_bytes(b"new") ++ destination = tmp_path / "destination" ++ destination.mkdir() ++ target = tmp_path / "missing" ++ link = destination / "entry" ++ link.symlink_to(target) ++ ++ copytree(str(source), str(destination)) ++ ++ assert not link.is_symlink() ++ assert link.read_bytes() == b"new" ++ assert not target.exists() + + + @pytest.mark.parametrize("existing", [False, True]) +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index 0504319..9d854da 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -26,10 +26,10 @@ + from virtualenv.create.creator import DEBUG_SCRIPT, Creator, get_env_debug_info + from virtualenv.create.pyenv_cfg import PyEnvCfg + from virtualenv.create.via_global_ref import api +-from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew ++from virtualenv.create.via_global_ref.builtin.cpython.common import is_mac_os_framework, is_macos_brew + from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix + from virtualenv.discovery.py_info import PythonInfo +-from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink ++from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_WIN, fs_is_case_sensitive, fs_supports_symlink + from virtualenv.run import cli_run, session_via_cli + + CURRENT = PythonInfo.current_system() +@@ -94,6 +94,25 @@ + + + CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] ++ ++ ++@pytest.mark.skipif(not fs_supports_symlink(), reason="symlinks are unavailable") ++@pytest.mark.skipif( ++ is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN), ++ reason="requires interpreter copy support", ++) ++def test_builtin_replaces_dangling_interpreter_link(tmp_path): ++ session = session_via_cli([str(tmp_path / "env"), "--creator", "builtin", "--copies", "--no-seed", "--activators="]) ++ session.creator.bin_dir.mkdir(parents=True) ++ target = tmp_path / "missing-python" ++ session.creator.exe.symlink_to(target) ++ ++ with session: ++ session.run() ++ ++ assert session.creator.exe.is_file() ++ assert not session.creator.exe.is_symlink() ++ assert not target.exists() + + + @pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113721.patch b/SPECS/python-virtualenv/CVE-2026-113721.patch new file mode 100644 index 00000000000..f8e6eb05f0a --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113721.patch @@ -0,0 +1,381 @@ +From d25f28699eefb1fc34a251e5adf2b5de844c36cd Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:25:09 -0700 +Subject: [PATCH] fix(create): replace generated file links +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/d25f28699eefb1fc34a251e5adf2b5de844c36cd.patch + +Backport-note: Rebase on the preceding dangling-link cleanup and regression +tests. Run import-hook coverage with the current interpreter because this +release still generates those hooks there. +--- + docs/changelog/3431.bugfix.rst | 5 + + src/virtualenv/activation/via_template.py | 11 +- + src/virtualenv/create/creator.py | 6 + + src/virtualenv/create/pyenv_cfg.py | 4 + + src/virtualenv/create/via_global_ref/api.py | 5 + + src/virtualenv/create/via_global_ref/venv.py | 5 + + .../seed/embed/via_app_data/pip_install/base.py | 4 + + src/virtualenv/util/path/__init__.py | 3 - + src/virtualenv/util/path/_sync.py | 9 ++ + tests/unit/create/test_creator.py | 116 ++++++++++++++++++++ + tests/unit/test_util.py | 11 ++ + 11 files changed, 164 insertions(+), 15 deletions(-) + +diff --git a/docs/changelog/3431.bugfix.rst b/docs/changelog/3431.bugfix.rst +new file mode 100644 +index 0000000..424d6ac +--- /dev/null ++++ b/docs/changelog/3431.bugfix.rst +@@ -0,0 +1,5 @@ ++Replace existing symbolic and hard links when writing environment configuration, activation scripts, import hooks, and ++seed launchers. Create generated text files exclusively so a replacement link cannot redirect the final file open. ++Before recreating an environment with the stdlib venv creator, replace executable-directory symlinks and Windows ++junctions with real directories. Remove symbolic and hard links inside that directory before creating interpreter files, ++including when switching from symlinks to copies. +diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py +index f40e454..8287096 100644 +--- a/src/virtualenv/activation/via_template.py ++++ b/src/virtualenv/activation/via_template.py +@@ -5,6 +5,8 @@ + import shlex + import sys + from abc import ABC, abstractmethod ++ ++from virtualenv.util.path import write_file + + from .activator import Activator + +@@ -57,14 +59,9 @@ + for template in templates: + text = self.instantiate_template(replacements, template, creator) + dest = to_folder / self.as_name(template) +- # remove the file if it already exists - this prevents permission +- # errors when the dest is not writable +- if dest.exists(): +- dest.unlink() +- # Powershell assumes Windows 1252 encoding when reading files without BOM ++ # PowerShell assumes Windows 1252 when the UTF-8 BOM is absent. + encoding = "utf-8-sig" if str(template).endswith(".ps1") else "utf-8" +- # use write_bytes to avoid platform specific line normalization (\n -> \r\n) +- dest.write_bytes(text.encode(encoding)) ++ write_file(dest, text.encode(encoding)) + generated.append(dest) + return generated + +diff --git a/src/virtualenv/create/creator.py b/src/virtualenv/create/creator.py +index 1e577ad..4eaccaa 100644 +--- a/src/virtualenv/create/creator.py ++++ b/src/virtualenv/create/creator.py +@@ -12,7 +12,7 @@ + from pathlib import Path + + from virtualenv.discovery.cached_py_info import LogCmd +-from virtualenv.util.path import safe_delete ++from virtualenv.util.path import safe_delete, write_file + from virtualenv.util.subprocess import run_cmd + from virtualenv.version import __version__ + +@@ -173,7 +173,7 @@ + # For information about cache directory tags, see: + # https://bford.info/cachedir/ + """).strip() +- cachedir_tag_file.write_text(cachedir_tag_text, encoding="utf-8") ++ write_file(cachedir_tag_file, cachedir_tag_text.encode("utf-8")) + + def set_pyenv_cfg(self): + self.pyenv_cfg.content = OrderedDict() +@@ -187,7 +187,7 @@ + # mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs + git_ignore = self.dest / ".gitignore" + if not git_ignore.exists(): +- git_ignore.write_text("# created by virtualenv automatically\n*\n", encoding="utf-8") ++ write_file(git_ignore, b"# created by virtualenv automatically\n*\n") + # Mercurial - does not support the .hgignore file inside a subdirectory directly, but only if included via the + # subinclude directive from root, at which point on might as well ignore the directory itself, see + # https://www.selenic.com/mercurial/hgignore.5.html for more details +diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py +index 1d1beac..4ac788d 100644 +--- a/src/virtualenv/create/pyenv_cfg.py ++++ b/src/virtualenv/create/pyenv_cfg.py +@@ -3,6 +3,8 @@ + import logging + import os + from collections import OrderedDict ++ ++from virtualenv.util.path import write_file + + LOGGER = logging.getLogger(__name__) + +@@ -40,7 +42,7 @@ + LOGGER.debug("\t%s", line) + text += line + text += "\n" +- self.path.write_text(text, encoding="utf-8") ++ write_file(self.path, text.encode("utf-8")) + + def refresh(self): + self.content = self._read_values(self.path) +diff --git a/src/virtualenv/create/via_global_ref/api.py b/src/virtualenv/create/via_global_ref/api.py +index 3f04f46..462eb93 100644 +--- a/src/virtualenv/create/via_global_ref/api.py ++++ b/src/virtualenv/create/via_global_ref/api.py +@@ -7,6 +7,7 @@ + + from virtualenv.create.creator import Creator, CreatorMeta + from virtualenv.info import fs_supports_symlink ++from virtualenv.util.path import write_file + + LOGGER = logging.getLogger(__name__) + +@@ -96,10 +97,10 @@ + if text: + pth = self.purelib / "_virtualenv.pth" + LOGGER.debug("create virtualenv import hook file %s", pth) +- pth.write_text("import _virtualenv", encoding="utf-8") ++ write_file(pth, b"import _virtualenv") + dest_path = self.purelib / "_virtualenv.py" + LOGGER.debug("create %s", dest_path) +- dest_path.write_text(text, encoding="utf-8") ++ write_file(dest_path, text.encode("utf-8")) + + def env_patch_text(self): + """Patch the distutils package to not be derailed by its configuration files.""" +diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py +index e852b46..4eab2de 100644 +--- a/src/virtualenv/create/via_global_ref/venv.py ++++ b/src/virtualenv/create/via_global_ref/venv.py +@@ -40,8 +40,13 @@ + return None + + def create(self): ++ # stdlib venv writes interpreter files, configuration and activation scripts in place, so a planted symlink or ++ # hard link would receive them outside the environment + for directory in (self.bin_dir, self.purelib, self.platlib): + ensure_within(self.dest, directory) ++ for path in (self.dest / "pyvenv.cfg", self.dest / ".gitignore", *self.bin_dir.iterdir()): ++ if path.is_symlink() or (path.is_file() and path.stat().st_nlink > 1): ++ path.unlink() + if self.can_be_inline: + self.create_inline() + else: +diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +index c734794..70cb698 100644 +--- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py ++++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +@@ -219,6 +219,10 @@ + + def _write_script(self, names, shebang, script_bytes, filenames, ext): + names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") ++ for name in names: ++ path = Path(self.target_dir) / (f"{name}.exe" if os.name == "nt" else name) ++ if path.is_symlink(): ++ path.unlink() + super()._write_script(names, shebang, script_bytes, filenames, ext) + + +diff --git a/src/virtualenv/util/path/__init__.py b/src/virtualenv/util/path/__init__.py +index f2cccc8..451630f 100644 +--- a/src/virtualenv/util/path/__init__.py ++++ b/src/virtualenv/util/path/__init__.py +@@ -1,7 +1,7 @@ + from __future__ import annotations + + from ._permission import make_exe, set_tree +-from ._sync import copy, copytree, ensure_dir, ensure_within, safe_delete, symlink ++from ._sync import copy, copytree, ensure_dir, ensure_within, safe_delete, symlink, write_file + from ._win import get_short_path_name + + __all__ = [ +@@ -14,4 +14,5 @@ + "safe_delete", + "set_tree", + "symlink", ++ "write_file", + ] +diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py +index f44a589..a29a810 100644 +--- a/src/virtualenv/util/path/_sync.py ++++ b/src/virtualenv/util/path/_sync.py +@@ -48,6 +48,14 @@ + ): + msg = f"environment directory is not a real directory: {folder}" + raise NotADirectoryError(msg) from None ++ ++ ++def write_file(path, data): ++ if path.is_symlink() or path.exists(): ++ path.unlink() ++ # Exclusive creation rejects links introduced after the old entry was removed. ++ with path.open("xb") as stream: ++ stream.write(data) + + + def ensure_safe_to_do(src, dest): +@@ -125,4 +133,5 @@ + "safe_delete", + "symlink", + "symlink", ++ "write_file", + ] +diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py +index 9d854da..d20f843 100644 +--- a/tests/unit/create/test_creator.py ++++ b/tests/unit/create/test_creator.py +@@ -113,6 +113,122 @@ + assert session.creator.exe.is_file() + assert not session.creator.exe.is_symlink() + assert not target.exists() ++ ++ ++@pytest.mark.parametrize("creator_name", [pytest.param("builtin", id="builtin"), pytest.param("venv", id="venv")]) ++@pytest.mark.parametrize("existing", [pytest.param(False, id="dangling"), pytest.param(True, id="existing")]) ++@pytest.mark.parametrize( ++ "generated", ++ [ ++ pytest.param(name, id=name) ++ for name in ("pyvenv.cfg", "activate", "pip", "CACHEDIR.TAG", ".gitignore", "_virtualenv.py", "_virtualenv.pth") ++ ], ++) ++def test_creator_replaces_generated_file_links(tmp_path, creator_name, existing, generated): ++ command = [ ++ str(tmp_path / "env"), ++ "--creator", ++ creator_name, ++ "--pip", ++ "embed", ++ "--no-periodic-update", ++ ] ++ session = session_via_cli(command) ++ session.creator.bin_dir.mkdir(parents=True) ++ target = session.creator.dest / "original" ++ if existing: ++ target.write_bytes(b"original") ++ path = ( ++ session.creator.bin_dir / ("pip.exe" if generated == "pip" and os.name == "nt" else generated) ++ if generated in {"activate", "pip"} ++ else session.creator.dest / generated ++ ) ++ if generated.startswith("_virtualenv"): ++ path = session.creator.purelib / generated ++ path.parent.mkdir(parents=True, exist_ok=True) ++ path.symlink_to(target) ++ with session: ++ session.run() ++ if existing and (generated == "CACHEDIR.TAG" or (generated == ".gitignore" and creator_name == "builtin")): ++ assert path.is_symlink() ++ else: ++ assert not path.is_symlink() ++ assert (target.read_bytes() if existing else target.exists()) == (b"original" if existing else False) ++ ++ ++@pytest.mark.parametrize( ++ "kind", ++ [ ++ pytest.param("file", id="file"), ++ pytest.param("symlink", id="symlink"), ++ pytest.param("dangling", id="dangling"), ++ pytest.param("junction", id="junction"), ++ ], ++) ++def test_venv_prepares_real_executable_directory(tmp_path, kind): ++ session = session_via_cli([str(tmp_path / "env"), "--creator", "venv", "--no-seed", "--activators="]) ++ session.creator.dest.mkdir() ++ if kind == "file": ++ session.creator.bin_dir.write_bytes(b"file") ++ else: ++ target = session.creator.dest / "empty" ++ if kind != "dangling": ++ target.mkdir() ++ if kind == "junction": ++ if not IS_WIN: ++ pytest.skip("requires Windows junctions") ++ cmd = shutil.which("cmd") ++ assert cmd is not None ++ subprocess.run( ++ [cmd, "/c", "mklink", "/J", str(session.creator.bin_dir), str(target)], ++ capture_output=True, ++ check=True, ++ ) ++ else: ++ if not fs_supports_symlink(): ++ pytest.skip("requires directory symlinks") ++ session.creator.bin_dir.symlink_to(target, target_is_directory=True) ++ if kind == "file": ++ with pytest.raises(NotADirectoryError, match="environment directory is not a real directory"): ++ session.run() ++ else: ++ session.run() ++ assert session.creator.bin_dir.resolve() == session.creator.bin_dir ++ ++ ++@pytest.mark.skipif(not CURRENT.has_venv or not fs_supports_symlink(), reason="requires venv and symlinks") ++@pytest.mark.skipif( ++ is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN), ++ reason="stdlib creator requires symlinks on this runtime", ++) ++def test_venv_recreate_with_copied_interpreter(tmp_path): ++ args = [str(tmp_path / "env"), "--creator", "venv", "--no-seed"] ++ cli_run([*args, "--symlinks"]) ++ session = cli_run([*args, "--copies"]) ++ assert (session.creator.exe.is_file(), session.creator.exe.resolve().parent) == ( ++ True, ++ session.creator.bin_dir.resolve(), ++ ) ++ ++ ++@pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv") ++@pytest.mark.parametrize("mode", [pytest.param("--copies", id="copies"), pytest.param("--symlinks", id="symlinks")]) ++def test_venv_replaces_hard_linked_files(tmp_path, mode): ++ if mode == "--symlinks" and not fs_supports_symlink(): ++ pytest.skip("requires symlinks") ++ if mode == "--copies" and (is_mac_os_framework(CURRENT) or is_macos_brew(CURRENT) or (IS_GRAALPY and not IS_WIN)): ++ pytest.skip("stdlib creator requires symlinks on this runtime") ++ session = session_via_cli([str(tmp_path / "env"), "--creator", "venv", "--no-seed", "--activators=", mode]) ++ session.creator.bin_dir.mkdir(parents=True) ++ names = ("python.exe", "pythonw.exe") if IS_WIN else ("python", "python3", f"python3.{CURRENT.version_info.minor}") ++ planted = [session.creator.dest / "pyvenv.cfg", *(session.creator.bin_dir / name for name in names)] ++ for path in planted: ++ (tmp_path / f"{path.name}.original").write_bytes(b"original") ++ os.link(tmp_path / f"{path.name}.original", path) ++ session.run() ++ assert [((tmp_path / f"{path.name}.original").read_bytes(), path.lstat().st_nlink) for path in planted] == [ ++ (b"original", 1) ++ ] * len(planted) + + + @pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="stdlib venv is unavailable") +diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py +index fce3f18..bb94bd0 100644 +--- a/tests/unit/test_util.py ++++ b/tests/unit/test_util.py +@@ -11,7 +11,7 @@ + + from virtualenv.info import fs_supports_symlink + from virtualenv.util.lock import ReentrantFileLock +-from virtualenv.util.path import copy, copytree, ensure_within ++from virtualenv.util.path import copy, copytree, ensure_within, write_file + from virtualenv.util.subprocess import run_cmd + + +@@ -172,6 +172,15 @@ + assert f"replace environment directory junction {link}" in caplog.text + + ++@pytest.mark.parametrize("existing", [pytest.param(False, id="new"), pytest.param(True, id="replace")]) ++def test_write_file_replaces_content(tmp_path, existing): ++ path = tmp_path / "generated" ++ if existing: ++ path.write_bytes(b"previous") ++ write_file(path, b"generated") ++ assert path.read_bytes() == b"generated" ++ ++ + def test_run_fail(tmp_path): + code, out, err = run_cmd([str(tmp_path)]) + assert err +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113722.patch b/SPECS/python-virtualenv/CVE-2026-113722.patch new file mode 100644 index 00000000000..e8441790bb2 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113722.patch @@ -0,0 +1,251 @@ +From 2519941bed33ee0d3ac99277f67913e439e476a9 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 03:41:16 -0700 +Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(seed):=20preserve=20interpre?= + =?UTF-8?q?ter=20paths=20in=20console=20scripts=20(#3417)?= +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/2519941bed33ee0d3ac99277f67913e439e476a9.patch +--- + docs/changelog/3417.bugfix.rst | 5 ++ + src/virtualenv/seed/embed/pip_invoke.py | 45 ++++++++++ + .../embed/via_app_data/pip_install/base.py | 15 ++++ + .../embed/test_bootstrap_link_via_app_data.py | 89 ++++++++++++++++++- + 4 files changed, 153 insertions(+), 1 deletion(-) + create mode 100644 docs/changelog/3417.bugfix.rst + +diff --git a/docs/changelog/3417.bugfix.rst b/docs/changelog/3417.bugfix.rst +new file mode 100644 +index 0000000..510469c +--- /dev/null ++++ b/docs/changelog/3417.bugfix.rst +@@ -0,0 +1,5 @@ ++Preserve spaces, quotes, and backslashes in interpreter paths when pip console scripts need a POSIX shell launcher. ++Paths containing tabs also use the shell launcher so the kernel does not split the interpreter name. ++ ++Apply the same launcher quoting after pip seeding and refresh the installed file records. Later independent pip installs ++or upgrades can overwrite these launchers; recreate affected environments after upgrading virtualenv. +diff --git a/src/virtualenv/seed/embed/pip_invoke.py b/src/virtualenv/seed/embed/pip_invoke.py +index b733c51..4da8975 100644 +--- a/src/virtualenv/seed/embed/pip_invoke.py ++++ b/src/virtualenv/seed/embed/pip_invoke.py +@@ -1,11 +1,20 @@ + from __future__ import annotations + ++import csv ++import hashlib ++import io + import logging ++import os ++from base64 import urlsafe_b64encode + from contextlib import contextmanager ++from importlib.metadata import distributions ++from pathlib import Path ++from stat import S_IMODE + from subprocess import Popen + + from virtualenv.discovery.cached_py_info import LogCmd + from virtualenv.seed.embed.base_embed import BaseEmbed ++from virtualenv.seed.embed.via_app_data.pip_install.base import shell_shebang + from virtualenv.seed.wheels import Version, get_wheel, pip_wheel_env_run + + LOGGER = logging.getLogger(__name__) +@@ -22,6 +31,42 @@ class PipInvoke(BaseEmbed): + with self.get_pip_install_cmd(creator.exe, for_py_version) as cmd: + env = pip_wheel_env_run(self.extra_search_dir, self.app_data, self.env) + self._execute(cmd, env) ++ if os.name == "posix": ++ self._rewrite_launchers(creator) ++ ++ def _rewrite_launchers(self, creator): ++ selected = self.distribution_to_versions() ++ exe = str(creator.exe).encode("utf-8") ++ for distribution in distributions(path=sorted({str(creator.purelib), str(creator.platlib)})): ++ if distribution.metadata["Name"].lower().replace("-", "_") not in selected: ++ continue ++ rows = list(csv.reader(io.StringIO(distribution.read_text("RECORD") or ""))) ++ changed = False ++ for row in rows: ++ path = Path(os.path.abspath(str(distribution.locate_file(row[0])))) ++ if path.parent != creator.script_dir or not path.is_file(): ++ continue ++ content = path.read_bytes() ++ if content.startswith(b"#!/bin/sh\n'''exec' "): ++ body = content.split(b"\n", 3)[3] ++ elif content.startswith(b"#!" + exe + b"\n") and len(exe.split()) > 1: ++ body = content.split(b"\n", 1)[1] ++ else: ++ continue ++ content = shell_shebang(str(creator.exe)) + body ++ mode = S_IMODE(path.stat().st_mode) & 0o755 ++ path.unlink() ++ with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode), "wb") as stream: ++ stream.write(content) ++ row[1:] = [ ++ "sha256=" + urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii"), ++ str(len(content)), ++ ] ++ changed = True ++ if changed: ++ record = next(row[0] for row in rows if row[0].endswith(".dist-info/RECORD")) ++ with Path(str(distribution.locate_file(record))).open("w", encoding="utf-8", newline="") as stream: ++ csv.writer(stream).writerows(rows) + + @staticmethod + def _execute(cmd, env): +diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +index d5e87ad..3823103 100644 +--- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py ++++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +@@ -3,6 +3,7 @@ from __future__ import annotations + import logging + import os + import re ++import shlex + import zipfile + from abc import ABC, abstractmethod + from configparser import ConfigParser +@@ -195,12 +196,26 @@ class ScriptMakerCustom(ScriptMaker): + self.version_info = version_info.major, version_info.minor + self.variants = {"", "X", "X.Y"} + self._name = name ++ self._raw_executable = str(executable) ++ ++ def _build_shebang(self, executable, post_interp): ++ shebang = super()._build_shebang(executable, post_interp) ++ if os.name != "posix" or len(shebang.split()) == 1: ++ return shebang ++ return shell_shebang(self._raw_executable, post_interp) + + def _write_script(self, names, shebang, script_bytes, filenames, ext): + names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") + super()._write_script(names, shebang, script_bytes, filenames, ext) + + ++def shell_shebang(executable, post_interp=b""): ++ # The exec line is read by both sh and Python; double-quoted backslashes survive both readers. ++ quoted = shlex.quote(executable).replace("\\", "'\"\\\\\"'") ++ return b"#!/bin/sh\n'''exec' " + quoted.encode("utf-8") + post_interp + b' "$0" "$@"\n' + b"' '''\n" ++ ++ + __all__ = [ + "PipInstall", ++ "shell_shebang", + ] +diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +index d59288a..ac62117 100644 +--- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py ++++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +@@ -1,12 +1,15 @@ + from __future__ import annotations + + import contextlib ++import csv ++import hashlib + import logging + import os + import sys ++from base64 import urlsafe_b64encode + from pathlib import Path + from stat import S_IWGRP, S_IWOTH, S_IWUSR +-from subprocess import Popen, check_call ++from subprocess import Popen, check_call, run + from threading import Thread + from typing import TYPE_CHECKING + +@@ -62,6 +65,90 @@ def unusable_cache(tmp_path): + return blocked / "cache" + + ++@pytest.mark.parametrize( ++ "name", ++ [ ++ pytest.param("plain", id="plain"), ++ pytest.param("two words", id="space"), ++ pytest.param("owner's env", id="apostrophe"), ++ pytest.param("$(echo a) `echo b`", id="command-substitution"), ++ pytest.param("a" * 120, id="long", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX shebang limit")), ++ pytest.param("env \\path", id="backslash", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX filename")), ++ pytest.param("env\tname", id="tab", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX filename")), ++ ], ++) ++@pytest.mark.parametrize( ++ "via_python", ++ [ ++ pytest.param(False, id="launcher"), ++ pytest.param( ++ True, id="python", marks=pytest.mark.skipif(os.name == "nt", reason="Windows launcher is an executable") ++ ), ++ ], ++) ++@pytest.mark.parametrize("seeder", ["app-data", "pip"]) ++def test_console_script_preserves_interpreter_path(tmp_path, current_fastest, name, via_python, seeder): ++ destination = os.path.join(str(tmp_path), name, "env") ++ run( ++ [ ++ sys.executable, ++ "-m", ++ "virtualenv", ++ destination, ++ "--seeder", ++ seeder, ++ "--creator", ++ current_fastest, ++ "--activators", ++ "", ++ "--no-periodic-update", ++ "--pip", ++ "embed", ++ ], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=60, ++ check=True, ++ ) ++ scripts = os.path.join(destination, "Scripts" if os.name == "nt" else "bin") ++ result = run( ++ [ ++ *([os.path.join(scripts, "python")] if via_python else []), ++ os.path.join(scripts, "pip.exe" if os.name == "nt" else "pip"), ++ "--version", ++ ], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ assert os.path.normcase(destination) in os.path.normcase(result.stdout) ++ ++ ++@pytest.mark.skipif(os.name != "posix", reason="POSIX shell launchers") ++@pytest.mark.parametrize( ++ ("name", "via_shell"), ++ [ ++ pytest.param("plain", False, id="plain"), ++ pytest.param("two words", True, id="space"), ++ pytest.param("env\tname", True, id="tab"), ++ ], ++) ++def test_pip_launcher_record_matches_contents(tmp_path, name, via_shell): ++ session = cli_run([str(tmp_path / name), "--seeder", "pip", "--pip", "embed", "--no-periodic-update"]) ++ record = next(session.creator.purelib.glob("pip-*.dist-info/RECORD")) ++ with record.open(encoding="utf-8", newline="") as stream: ++ row = next(row for row in csv.reader(stream) if row[0].endswith("/bin/pip")) ++ content = (session.creator.script_dir / "pip").read_bytes() ++ assert content.startswith(b"#!/bin/sh\n") is via_shell ++ assert row[1:] == [ ++ "sha256=" + urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii"), ++ str(len(content)), ++ ] ++ ++ + @pytest.mark.slow + @pytest.mark.parametrize("copies", [False, True] if fs_supports_symlink() else [True]) + def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies, for_py_version): # noqa: PLR0915 +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-113723.patch b/SPECS/python-virtualenv/CVE-2026-113723.patch new file mode 100644 index 00000000000..6f42220396c --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113723.patch @@ -0,0 +1,251 @@ +From 5f48d711731311428aebfae61d46957762155b22 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sat, 17 Oct 2026 02:35:17 -0700 +Subject: [PATCH] fix(activation): preserve Bash compatibility-mode prompts +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/5f48d711731311428aebfae61d46957762155b22.patch + +Backport-note: Apply the final squashed upstream implementation to the older +activation template and test harness. Apply CVE-2026-115586.patch afterward to +fix the subsequent prompt-state regression. +--- + docs/changelog/3418.bugfix.rst | 5 + + src/virtualenv/activation/bash/activate.sh | 23 ++++ + tests/unit/activation/test_bash.py | 153 ++++++++++++++++++++++++++++ + 3 files changed, 177 insertions(+), 4 deletions(-) + +diff --git a/docs/changelog/3418.bugfix.rst b/docs/changelog/3418.bugfix.rst +new file mode 100644 +index 0000000..81f7d5a +--- /dev/null ++++ b/docs/changelog/3418.bugfix.rst +@@ -0,0 +1,5 @@ ++Leave ``PS1`` unchanged when sourcing the POSIX activation script in a shell other than bash or zsh, whose prompt ++escaping rules virtualenv handles; ``VIRTUAL_ENV_PROMPT`` stays available to custom prompts in other shells. ++ ++In bash, keep the prompt literal under older compatibility levels, with ``promptvars`` disabled and in posix mode, and ++avoid ``nounset`` errors after ``VIRTUAL_ENV_PROMPT`` is unset. +diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh +index 0221057..49825ec 100644 +--- a/src/virtualenv/activation/bash/activate.sh ++++ b/src/virtualenv/activation/bash/activate.sh +@@ -103,9 +103,28 @@ + export TK_LIBRARY + fi + +-if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then ++if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] && ++ { [ -n "${BASH_VERSION-}" ] || [ -n "${ZSH_VERSION-}" ]; }; then + _OLD_VIRTUAL_PS1="${PS1-}" +- PS1="(${VIRTUAL_ENV_PROMPT}) ${PS1-}" ++ # Expand the value only after bash decodes prompt escapes. Without expansion, only backslashes need doubling. ++ # zsh also needs dollar signs, backticks and percent signs escaped, including when PROMPT_SUBST is enabled later. ++ if [ -n "${BASH_VERSION-}" ] && { shopt -q promptvars || shopt -qo posix; }; then ++ PS1='(${VIRTUAL_ENV_PROMPT-}) '"${PS1-}" ++ else ++ if [ -n "${BASH_VERSION-}" ]; then ++ # A literal replacement value avoids compatibility-dependent quote removal. ++ _VIRTUAL_BACKSLASH='\\\\' ++ _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/$_VIRTUAL_BACKSLASH}" ++ unset _VIRTUAL_BACKSLASH ++ else ++ _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}" ++ fi ++ PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" ++ unset _VIRTUAL_PROMPT ++ fi + export PS1 + fi + +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index 89a5dd4..980e0b1 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -1,6 +1,8 @@ + from __future__ import annotations + ++import os + import shlex ++import shutil + import subprocess + from argparse import Namespace + from types import SimpleNamespace +@@ -9,6 +11,149 @@ + + from virtualenv.activation import BashActivator + from virtualenv.info import IS_WIN ++ ++_BASH = shutil.which("bash") ++ ++ ++@pytest.fixture ++def bash_activate_script(tmp_path): ++ def generate(prompt=None, name="env"): ++ creator = SimpleNamespace( ++ dest=tmp_path / name, ++ bin_dir=tmp_path / name / "bin", ++ interpreter=SimpleNamespace(), ++ pyenv_cfg={}, ++ env_name=name, ++ ) ++ creator.bin_dir.mkdir(parents=True) ++ BashActivator(Namespace(prompt=prompt)).generate(creator) ++ return creator.bin_dir / "activate" ++ ++ return generate ++ ++ ++def _interactive_bash_prompt(script, tmp_path, before="", after=""): ++ result = subprocess.run( ++ [_BASH, "--noprofile", "--norc", "-i"], ++ input=f"{before}\nPS1=__END__\n. {BashActivator.quote(str(script))}\n{after}\nexit\n", ++ cwd=tmp_path, ++ env={**os.environ, "PS1": "", "PROMPT_COMMAND": ""}, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ return result.stderr ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize( ++ ("setup", "expected"), ++ [ ++ pytest.param("", "(${VIRTUAL_ENV_PROMPT-}) base ", id="bash"), ++ pytest.param("unset BASH_VERSION ZSH_VERSION;", "base ", id="unrecognized-shell"), ++ pytest.param("VIRTUAL_ENV_DISABLE_PROMPT=1;", "base ", id="disabled-prompt"), ++ ], ++) ++def test_prompt_requires_known_shell(tmp_path, bash_activate_script, setup, expected): ++ script = bash_activate_script(prompt="example") ++ result = subprocess.run( ++ [ ++ _BASH, ++ "--noprofile", ++ "--norc", ++ "-c", ++ f'{setup} PS1="base "; . "$1"; printf "%s|%s" "$PS1" "$VIRTUAL_ENV_PROMPT"; ' ++ 'deactivate; printf "|%s" "$PS1"', ++ "test", ++ str(script), ++ ], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ assert result.stdout == f"{expected}|example|base " ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize( ++ "prompt", ++ [ ++ pytest.param("plain", id="plain"), ++ pytest.param(r"e\\z", id="backslashes"), ++ pytest.param("e\\", id="trailing-backslash"), ++ pytest.param("space * ? [x]", id="spaces-and-globs"), ++ pytest.param("x$HOMEy", id="variable"), ++ pytest.param("x$(touch PWNED)y", id="command-substitution"), ++ pytest.param("x`touch PWNED`y", id="backticks"), ++ ], ++) ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++@pytest.mark.parametrize("promptvars", [True, False], ids=["promptvars-on", "promptvars-off"]) ++def test_bash_prompt_renders_name_literally(tmp_path, bash_activate_script, prompt, compatibility, promptvars): ++ script = bash_activate_script(prompt=prompt) ++ before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" ++ ++ rendered = _interactive_bash_prompt(script, tmp_path, before=before) ++ ++ assert not (tmp_path / "PWNED").exists() ++ assert f"({prompt}) __END__" in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("name", ["plain", "x$(touch PWNED)y", "x`touch PWNED`y"]) ++def test_bash_prompt_from_directory_name(tmp_path, bash_activate_script, name): ++ script = bash_activate_script(name=name) ++ ++ rendered = _interactive_bash_prompt(script, tmp_path) ++ ++ assert not (tmp_path / "PWNED").exists() ++ assert f"({name}) __END__" in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize( ++ ("before", "after", "expected"), ++ [ ++ pytest.param("shopt -u promptvars", "", "(x$(touch PWNED)y) __END__", id="promptvars-disabled"), ++ pytest.param( ++ "shopt -u promptvars; set -o posix", "", "(x$(touch PWNED)y) __END__", id="promptvars-disabled-posix" ++ ), ++ pytest.param("", "set -u; unset VIRTUAL_ENV_PROMPT", "() __END__", id="unset-with-nounset"), ++ ], ++) ++def test_bash_prompt_options(tmp_path, bash_activate_script, before, after, expected): ++ script = bash_activate_script(prompt="x$(touch PWNED)y") ++ ++ rendered = _interactive_bash_prompt(script, tmp_path, before=before, after=after) ++ ++ assert not (tmp_path / "PWNED").exists() ++ assert expected in rendered ++ assert "unbound variable" not in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") ++@pytest.mark.parametrize("shell", ["dash", "ksh", "ksh93", "mksh"]) ++def test_bash_activation_preserves_other_shell_prompts(tmp_path, bash_activate_script, shell): ++ executable = shutil.which(shell) ++ if executable is None: ++ pytest.skip(f"{shell} is not installed") ++ script = bash_activate_script(prompt="x$(touch PWNED)y") ++ result = subprocess.run( ++ [executable, "-c", 'PS1="base "; . "$1"; printf "%s|%s" "$PS1" "$VIRTUAL_ENV_PROMPT"', "test", str(script)], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ assert result.stdout == "base |x$(touch PWNED)y" ++ assert not (tmp_path / "PWNED").exists() + + + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") +@@ -92,7 +237,7 @@ + creator.bin_dir.rmdir() + creator.dest.rmdir() + result = subprocess.run( +- ["bash", "-c", f"source {shlex.quote(str(script))}"], ++ [_BASH, "-c", f"source {shlex.quote(str(script))}"], + capture_output=True, + text=True, + cwd=str(tmp_path), +@@ -124,6 +269,10 @@ + return super().activate_call(script) + " || exit 1" + + def print_prompt(self): +- return self.print_os_env_var("PS1") ++ return ( ++ "case \"$PS1\" in '(${VIRTUAL_ENV_PROMPT-}) '*) " ++ "printf '(%s) %s\\n' \"$VIRTUAL_ENV_PROMPT\" \"${PS1#'(${VIRTUAL_ENV_PROMPT-}) '}\";; " ++ "*) printf '%s\\n' \"$PS1\";; esac" ++ ) + + activation_tester(Bash) +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113725.patch b/SPECS/python-virtualenv/CVE-2026-113725.patch new file mode 100644 index 00000000000..afedc7798e1 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113725.patch @@ -0,0 +1,55 @@ +From c3104e02474b652832a9bbaae79f6e1696296932 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Sun, 11 Oct 2026 17:36:53 -0700 +Subject: [PATCH] test(activation): cover Bash two-pass prompt escaping +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/c3104e02474b652832a9bbaae79f6e1696296932.patch + +Backport-note: The production fix for CVE-2026-113725 is provided by +CVE-2026-113723.patch (5f48d711), whose prompt rewrite supersedes c3104e02, +with later prompt-state hardening in CVE-2026-115586.patch. This patch carries +only regression tests and the upstream changelog; it does not change the +activator or the stdlib creator. +--- + docs/changelog/3390.bugfix.rst | 3 +++ + tests/unit/activation/test_bash.py | 6 ++++++ + 2 files changed, 9 insertions(+) + +diff --git a/docs/changelog/3390.bugfix.rst b/docs/changelog/3390.bugfix.rst +new file mode 100644 +index 0000000..de64ad8 +--- /dev/null ++++ b/docs/changelog/3390.bugfix.rst +@@ -0,0 +1,3 @@ ++Escape the environment name in the bash activator for both of bash's ``PS1`` passes, so a name or ``--prompt`` value ++with a backslash before ``$(...)`` or backticks no longer runs as a command each time bash draws the prompt - by ++:user:`gaborbernat`. +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index ee0c805..c5b4dce 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -68,6 +68,8 @@ + pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"), + pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"), + pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"), ++ pytest.param(r"x\$(touch PWNED)y", "env", id="prompt-backslash-command-substitution"), ++ pytest.param(r"x\`touch PWNED`y", "env", id="prompt-backslash-backticks"), + pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), + pytest.param(None, "A__VIRTUAL_NAME__B/x'$(touch PWNED)'y", id="placeholder-in-parent-dir"), + ], +@@ -84,6 +86,10 @@ + [ + pytest.param("myenv", id="plain"), + pytest.param(r"a%n$HOME\b`c`", id="special-characters"), ++ pytest.param(r"a\$HOME", id="backslash-before-dollar"), ++ pytest.param(r"a\`c`", id="backslash-before-backtick"), ++ pytest.param(r"x\\y", id="doubled-backslash"), ++ pytest.param("e\\", id="trailing-backslash"), + ], + ) + def test_prompt_renders_name_literally(prompt_after_activate, prompt): +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113726.patch b/SPECS/python-virtualenv/CVE-2026-113726.patch new file mode 100644 index 00000000000..7cd0aff44ce --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113726.patch @@ -0,0 +1,115 @@ +From 5b915367da82bd024b2a0d28d2f98007034b4aa2 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Fri, 2 Oct 2026 06:40:23 -0700 +Subject: [PATCH] test(activation): cover Zsh prompt substitution +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/5b915367da82bd024b2a0d28d2f98007034b4aa2.patch + +Backport-note: The production fix for CVE-2026-113726 is provided by +CVE-2026-113723.patch (5f48d711), with later prompt-state hardening in +CVE-2026-115586.patch. This patch carries only the Bash/Zsh regression +coverage; keep these prompt patches together. +--- + tests/unit/activation/test_bash.py | 55 +++++++++++++++++++++++++++++++++--- + 1 file changed, 50 insertions(+), 5 deletions(-) + +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index 980e0b1..1c081ab 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -32,9 +32,13 @@ + return generate + + +-def _interactive_bash_prompt(script, tmp_path, before="", after=""): ++def _interactive_prompt(script, tmp_path, before="", after="", shell="bash"): ++ executable = shutil.which(shell) ++ if executable is None: ++ pytest.skip(f"{shell} is not installed") ++ flags = {"bash": ["--noprofile", "--norc", "-i"], "zsh": ["-f", "-i"]}[shell] + result = subprocess.run( +- [_BASH, "--noprofile", "--norc", "-i"], ++ [executable, *flags], + input=f"{before}\nPS1=__END__\n. {BashActivator.quote(str(script))}\n{after}\nexit\n", + cwd=tmp_path, + env={**os.environ, "PS1": "", "PROMPT_COMMAND": ""}, +@@ -45,6 +49,47 @@ + check=True, + ) + return result.stderr ++ ++ ++@pytest.fixture(params=["bash", "zsh"]) ++def prompt_after_activate(request, tmp_path, bash_activate_script): ++ def build(prompt, name): ++ script = bash_activate_script(prompt=prompt, name=name) ++ before = "setopt prompt_subst" if request.param == "zsh" else "" ++ return tmp_path, _interactive_prompt(script, tmp_path, before=before, shell=request.param) ++ ++ return build ++ ++ ++@pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") ++@pytest.mark.parametrize( ++ ("prompt", "name"), ++ [ ++ pytest.param("x$(touch PWNED)y", "env", id="prompt-command-substitution"), ++ pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"), ++ pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"), ++ pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), ++ ], ++) ++def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): ++ work_dir, _ = prompt_after_activate(prompt, name) ++ ++ assert not (work_dir / "PWNED").exists() ++ ++ ++@pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") ++@pytest.mark.parametrize( ++ "prompt", ++ [ ++ pytest.param("myenv", id="plain"), ++ pytest.param(r"a%n$HOME\b`c`", id="special-characters"), ++ ], ++) ++def test_prompt_renders_name_literally(prompt_after_activate, prompt): ++ work_dir, rendered = prompt_after_activate(prompt, "env") ++ ++ assert f"({prompt}) __END__" in rendered ++ assert not (work_dir / "PWNED").exists() + + + @pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") +@@ -98,7 +143,7 @@ + script = bash_activate_script(prompt=prompt) + before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" + +- rendered = _interactive_bash_prompt(script, tmp_path, before=before) ++ rendered = _interactive_prompt(script, tmp_path, before=before) + + assert not (tmp_path / "PWNED").exists() + assert f"({prompt}) __END__" in rendered +@@ -109,7 +154,7 @@ + def test_bash_prompt_from_directory_name(tmp_path, bash_activate_script, name): + script = bash_activate_script(name=name) + +- rendered = _interactive_bash_prompt(script, tmp_path) ++ rendered = _interactive_prompt(script, tmp_path) + + assert not (tmp_path / "PWNED").exists() + assert f"({name}) __END__" in rendered +@@ -129,7 +174,7 @@ + def test_bash_prompt_options(tmp_path, bash_activate_script, before, after, expected): + script = bash_activate_script(prompt="x$(touch PWNED)y") + +- rendered = _interactive_bash_prompt(script, tmp_path, before=before, after=after) ++ rendered = _interactive_prompt(script, tmp_path, before=before, after=after) + + assert not (tmp_path / "PWNED").exists() + assert expected in rendered +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113732.patch b/SPECS/python-virtualenv/CVE-2026-113732.patch new file mode 100644 index 00000000000..5f0b41260ed --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113732.patch @@ -0,0 +1,112 @@ +From 3bddeaf8d02c3ee7f576503efb082f8f494cfcab Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Wed, 30 Sep 2026 22:59:45 -0700 +Subject: [PATCH] fix(activation): quote Fish values using Fish rules +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/3bddeaf8d02c3ee7f576503efb082f8f494cfcab.patch + +Backport-note: Include the imports and Fish executable lookup missing from the +older test module, using its type-checking conventions. +--- + src/virtualenv/activation/fish/__init__.py | 5 +++ + tests/unit/activation/test_fish.py | 46 ++++++++++++++++++++++++++-- + 2 files changed, 48 insertions(+), 3 deletions(-) + +diff --git a/src/virtualenv/activation/fish/__init__.py b/src/virtualenv/activation/fish/__init__.py +index 2626356..d91da4f 100644 +--- a/src/virtualenv/activation/fish/__init__.py ++++ b/src/virtualenv/activation/fish/__init__.py +@@ -6,6 +6,11 @@ + class FishActivator(ViaTemplateActivator): + def templates(self): + yield "activate.fish" ++ @staticmethod ++ def quote(string: str) -> str: ++ # fish reads \\ and \' as escapes inside single quotes, unlike POSIX sh, so shlex.quote's output lets a ++ # backslash before a quote end the literal early ++ return "'" + string.replace("\\", "\\\\").replace("'", "\\'") + "'" + + def replacements(self, creator, dest): + data = super().replacements(creator, dest) +diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py +index cd901f4..fc3a65d 100644 +--- a/tests/unit/activation/test_fish.py ++++ b/tests/unit/activation/test_fish.py +@@ -1,17 +1,23 @@ + from __future__ import annotations + + import os +-import shlex + import shutil + import subprocess + import sys + from argparse import Namespace + from types import SimpleNamespace ++from typing import TYPE_CHECKING + + import pytest + + from virtualenv.activation import FishActivator + from virtualenv.info import IS_WIN ++from virtualenv.run import cli_run ++ ++if TYPE_CHECKING: ++ from pathlib import Path ++ ++FISH = shutil.which("fish") + + + @pytest.mark.parametrize( +@@ -50,11 +56,45 @@ + + # THEN + if present: +- assert f"set -gx TCL_LIBRARY {shlex.quote(tcl_lib)}\n" in content +- assert f"set -gx TK_LIBRARY {shlex.quote(tk_lib)}\n" in content ++ assert f"set -gx TCL_LIBRARY {FishActivator.quote(tcl_lib)}\n" in content ++ assert f"set -gx TK_LIBRARY {FishActivator.quote(tk_lib)}\n" in content + else: + assert "if test -n ''\n if set -q TCL_LIBRARY;" in content + assert "if test -n ''\n if set -q TK_LIBRARY;" in content ++ ++ ++@pytest.mark.skipif(IS_WIN, reason="fish is not available on Windows") ++@pytest.mark.skipif(FISH is None, reason="fish is not installed") ++@pytest.mark.parametrize( ++ ("prompt", "env_name"), ++ [ ++ pytest.param(r"x\'$(touch PWNED)'\'", "env", id="prompt-backslash-quote"), ++ pytest.param(None, r"x\'$(touch PWNED)'\'", id="dirname-backslash-quote"), ++ ], ++) ++def test_fish_activate_does_not_run_commands( ++ tmp_path: Path, current_fastest: str, prompt: str | None, env_name: str ++) -> None: ++ dest = tmp_path / env_name ++ args = ["--without-pip", str(dest), "--creator", current_fastest, "--no-periodic-update", "--activators", "fish"] ++ if prompt is not None: ++ args += ["--prompt", prompt] ++ cli_run(args) ++ (work_dir := tmp_path / "workdir").mkdir() ++ ++ # the path is passed as an argument so the driver cannot expand a payload dir name while locating the script ++ result = subprocess.run( ++ [FISH, "--no-config", "-c", 'source $argv[1]; printf "%s" "$VIRTUAL_ENV"', str(dest / "bin" / "activate.fish")], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ cwd=str(work_dir), ++ timeout=60, ++ check=False, ++ ) ++ ++ assert not (work_dir / "PWNED").exists() ++ assert result.stdout == str(dest), result.stderr + + + @pytest.mark.skipif(IS_WIN, reason="fish is not available on Windows") +-- +2.45.4 diff --git a/SPECS/python-virtualenv/CVE-2026-113776.patch b/SPECS/python-virtualenv/CVE-2026-113776.patch new file mode 100644 index 00000000000..30ee21fb494 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-113776.patch @@ -0,0 +1,99 @@ +From 602b26623a724827ff171bf11fef4ad186d01f74 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Wed, 30 Sep 2026 23:01:05 -0700 +Subject: [PATCH] Merge commit from fork + +instantiate_template replaced one placeholder at a time across the whole +template, so a placeholder name inside an already quoted value, such as a +parent directory named __VIRTUAL_NAME__, was rewritten from inside the +finished literal. The second value's quotes closed the first, and the rest +of the path ran as code in bash, fish, xonsh and activate_this.py; in csh +the prompt display tokens broke activate.csh the same way. + +Build every token's final text first and substitute them in one regex pass, +with the csh display tokens joining that pass through substitutions(). + +Upstream-reference: https://github.com/pypa/virtualenv/commit/602b26623a724827ff171bf11fef4ad186d01f74.patch +--- + src/virtualenv/activation/via_template.py | 21 +++++++++++++++++---- + tests/conftest.py | 3 ++- + tests/unit/activation/test_bash.py | 3 ++- + 3 files changed, 21 insertions(+), 6 deletions(-) + +diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py +index 85f9326..f40e454 100644 +--- a/src/virtualenv/activation/via_template.py ++++ b/src/virtualenv/activation/via_template.py +@@ -1,6 +1,7 @@ + from __future__ import annotations + + import os ++import re + import shlex + import sys + from abc import ABC, abstractmethod +@@ -74,10 +75,22 @@ class ViaTemplateActivator(Activator, ABC): + # read content as binary to avoid platform specific line normalization (\n -> \r\n) + binary = read_binary(self.__module__, template) + text = binary.decode("utf-8", errors="strict") +- for key, value in replacements.items(): +- value_uni = self._repr_unicode(creator, value) +- text = text.replace(key, self.quote(value_uni)) +- return text ++ substitutions = self.substitutions(replacements, creator) ++ # Substitute once so placeholder names inside quoted paths remain data. ++ pattern = re.compile("|".join(re.escape(key) for key in sorted(substitutions, key=len, reverse=True))) ++ return pattern.sub(lambda match: substitutions[match.group()], text) ++ ++ def substitutions(self, replacements, creator): ++ """ ++ Map each placeholder to the final text that replaces it in the template. ++ ++ :param replacements: the raw placeholder values ++ :param creator: the creator of the environment ++ ++ :returns: the placeholder to substituted text mapping ++ ++ """ ++ return {key: self.quote(self._repr_unicode(creator, value)) for key, value in replacements.items()} + + @staticmethod + def _repr_unicode(creator, value): # noqa: ARG004 +diff --git a/tests/conftest.py b/tests/conftest.py +index e4fc284..8c6ced7 100644 +--- a/tests/conftest.py ++++ b/tests/conftest.py +@@ -281,7 +281,8 @@ def is_inside_ci(): + + @pytest.fixture(scope="session") + def special_char_name(): +- base = "'\";&&e-$ Γ¨Ρ€Ρ‚πŸš’β™žδΈ­η‰‡-j" ++ # __VIRTUAL_NAME__ must stay data when embedded in an environment path. ++ base = "'\";&&e-$ __VIRTUAL_NAME__Γ¨Ρ€Ρ‚πŸš’β™žδΈ­η‰‡-j" + if IS_WIN: + # get rid of invalid characters on Windows + base = base.replace('"', "") +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index 1c081ab..ee0c805 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -23,7 +23,7 @@ def bash_activate_script(tmp_path): + bin_dir=tmp_path / name / "bin", + interpreter=SimpleNamespace(), + pyenv_cfg={}, +- env_name=name, ++ env_name=(tmp_path / name).name, + ) + creator.bin_dir.mkdir(parents=True) + BashActivator(Namespace(prompt=prompt)).generate(creator) +@@ -69,6 +69,7 @@ def prompt_after_activate(request, tmp_path, bash_activate_script): + pytest.param("x`touch PWNED`y", "env", id="prompt-backticks"), + pytest.param("x${$(touch PWNED)}y", "env", id="prompt-parameter-expansion"), + pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), ++ pytest.param(None, "A__VIRTUAL_NAME__B/x'$(touch PWNED)'y", id="placeholder-in-parent-dir"), + ], + ) + def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): +-- +2.45.4 + diff --git a/SPECS/python-virtualenv/CVE-2026-115586.patch b/SPECS/python-virtualenv/CVE-2026-115586.patch new file mode 100644 index 00000000000..a4fc6e0d111 --- /dev/null +++ b/SPECS/python-virtualenv/CVE-2026-115586.patch @@ -0,0 +1,325 @@ +From 3598e201f6f044e389136b2347a8a919ac03c5f4 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= +Date: Thu, 22 Oct 2026 13:06:22 -0700 +Subject: [PATCH] fix(activation): escape prompts for every prompt state +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Upstream-reference: https://github.com/pypa/virtualenv/commit/3598e201f6f044e389136b2347a8a919ac03c5f4.patch + +Backport-note: Backport the prompt-state runtime changes, ordinary regression +tests, and related changelog only. Exclude unrelated CI, CodeQL, property- +test, and redirect changes from the upstream commit. Preserve the older PS1 +export behavior while escaping the stored prompt for inherited child shells. +--- + src/virtualenv/activation/bash/activate.sh | 43 ++++++--- + tests/unit/activation/test_bash.py | 129 +++++++++++++++++++++++++--- + tests/unit/activation/conftest.py | 5 + + docs/changelog/3466.bugfix.rst | 6 + + 4 files changed, 151 insertions(+), 32 deletions(-) + +diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh +index 49825ec..b89f018 100644 +--- a/src/virtualenv/activation/bash/activate.sh ++++ b/src/virtualenv/activation/bash/activate.sh +@@ -43,6 +43,11 @@ + PS1="$_OLD_VIRTUAL_PS1" + export PS1 + unset _OLD_VIRTUAL_PS1 ++ fi ++ ++ if [ -n "${_OLD_VIRTUAL_PSVAR+x}" ] ; then ++ psvar[9]="$_OLD_VIRTUAL_PSVAR" ++ unset _OLD_VIRTUAL_PSVAR + fi + + unset VIRTUAL_ENV +@@ -106,25 +111,29 @@ + if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] && + { [ -n "${BASH_VERSION-}" ] || [ -n "${ZSH_VERSION-}" ]; }; then + _OLD_VIRTUAL_PS1="${PS1-}" +- # Expand the value only after bash decodes prompt escapes. Without expansion, only backslashes need doubling. +- # zsh also needs dollar signs, backticks and percent signs escaped, including when PROMPT_SUBST is enabled later. +- if [ -n "${BASH_VERSION-}" ] && { shopt -q promptvars || shopt -qo posix; }; then +- PS1='(${VIRTUAL_ENV_PROMPT-}) '"${PS1-}" ++ if [ -n "${BASH_VERSION-}" ]; then ++ # Escape for both prompt passes regardless of current options or a child shell's options. Concatenation avoids ++ # compatibility-dependent backslash removal in parameter-substitution replacement strings. ++ _VIRTUAL_REST=$VIRTUAL_ENV_PROMPT ++ _VIRTUAL_PROMPT= ++ while [ -n "$_VIRTUAL_REST" ]; do ++ _VIRTUAL_CHAR=${_VIRTUAL_REST%"${_VIRTUAL_REST#?}"} ++ _VIRTUAL_REST=${_VIRTUAL_REST#?} ++ case $_VIRTUAL_CHAR in ++ \\) _VIRTUAL_PROMPT="$_VIRTUAL_PROMPT\\\\\\\\" ;; ++ \$ | \`) _VIRTUAL_PROMPT="$_VIRTUAL_PROMPT\\$_VIRTUAL_CHAR" ;; ++ *) _VIRTUAL_PROMPT="$_VIRTUAL_PROMPT$_VIRTUAL_CHAR" ;; ++ esac ++ done ++ unset _VIRTUAL_REST _VIRTUAL_CHAR + else +- if [ -n "${BASH_VERSION-}" ]; then +- # A literal replacement value avoids compatibility-dependent quote removal. +- _VIRTUAL_BACKSLASH='\\\\' +- _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/$_VIRTUAL_BACKSLASH}" +- unset _VIRTUAL_BACKSLASH +- else +- _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}" +- fi +- PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" +- unset _VIRTUAL_PROMPT ++ # zsh inserts psvar entries as data rather than parsing their contents as prompt syntax. ++ _OLD_VIRTUAL_PSVAR="${psvar[9]-}" ++ psvar[9]="$VIRTUAL_ENV_PROMPT" ++ _VIRTUAL_PROMPT='%9v' + fi ++ PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" ++ unset _VIRTUAL_PROMPT + export PS1 + fi + +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index c5b4dce..f00f8c1 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -13,6 +13,16 @@ + from virtualenv.info import IS_WIN + + _BASH = shutil.which("bash") ++ ++ ++def _expected_bash_prompt(prompt, promptvars=True): ++ if not IS_WIN and os.geteuid() == 0: ++ prompt = prompt.replace("$", "#") ++ return prompt if promptvars else prompt.replace("\\", "\\\\").replace("`", "\\`") ++ ++ ++def _last_prompt(rendered): ++ return rendered.rsplit("__END__", 1)[0].rsplit("\n", 1)[-1] + "__END__" + + + @pytest.fixture +@@ -56,7 +66,7 @@ + def build(prompt, name): + script = bash_activate_script(prompt=prompt, name=name) + before = "setopt prompt_subst" if request.param == "zsh" else "" +- return tmp_path, _interactive_prompt(script, tmp_path, before=before, shell=request.param) ++ return tmp_path, _interactive_prompt(script, tmp_path, before=before, shell=request.param), request.param + + return build + +@@ -75,7 +85,7 @@ + ], + ) + def test_prompt_does_not_run_commands(prompt_after_activate, prompt, name): +- work_dir, _ = prompt_after_activate(prompt, name) ++ work_dir, _, _ = prompt_after_activate(prompt, name) + + assert not (work_dir / "PWNED").exists() + +@@ -93,9 +103,10 @@ + ], + ) + def test_prompt_renders_name_literally(prompt_after_activate, prompt): +- work_dir, rendered = prompt_after_activate(prompt, "env") +- +- assert f"({prompt}) __END__" in rendered ++ work_dir, rendered, shell = prompt_after_activate(prompt, "env") ++ ++ expected = _expected_bash_prompt(prompt) if shell == "bash" else prompt ++ assert f"({expected}) __END__" in rendered + assert not (work_dir / "PWNED").exists() + + +@@ -103,7 +114,7 @@ + @pytest.mark.parametrize( + ("setup", "expected"), + [ +- pytest.param("", "(${VIRTUAL_ENV_PROMPT-}) base ", id="bash"), ++ pytest.param("", "(example) base ", id="bash"), + pytest.param("unset BASH_VERSION ZSH_VERSION;", "base ", id="unrecognized-shell"), + pytest.param("VIRTUAL_ENV_DISABLE_PROMPT=1;", "base ", id="disabled-prompt"), + ], +@@ -153,7 +164,7 @@ + rendered = _interactive_prompt(script, tmp_path, before=before) + + assert not (tmp_path / "PWNED").exists() +- assert f"({prompt}) __END__" in rendered ++ assert f"({_expected_bash_prompt(prompt, promptvars)}) __END__" in rendered + + + @pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") +@@ -164,7 +175,7 @@ + rendered = _interactive_prompt(script, tmp_path) + + assert not (tmp_path / "PWNED").exists() +- assert f"({name}) __END__" in rendered ++ assert f"({_expected_bash_prompt(name)}) __END__" in rendered + + + @pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") +@@ -175,7 +186,7 @@ + pytest.param( + "shopt -u promptvars; set -o posix", "", "(x$(touch PWNED)y) __END__", id="promptvars-disabled-posix" + ), +- pytest.param("", "set -u; unset VIRTUAL_ENV_PROMPT", "() __END__", id="unset-with-nounset"), ++ pytest.param("", "set -u; unset VIRTUAL_ENV_PROMPT", "(x$(touch PWNED)y) __END__", id="unset-with-nounset"), + ], + ) + def test_bash_prompt_options(tmp_path, bash_activate_script, before, after, expected): +@@ -184,8 +195,99 @@ + rendered = _interactive_prompt(script, tmp_path, before=before, after=after) + + assert not (tmp_path / "PWNED").exists() ++ if not IS_WIN and os.geteuid() == 0: ++ expected = expected.replace("$", "#") + assert expected in rendered + assert "unbound variable" not in rendered ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++def test_bash_prompt_stored_form(tmp_path, bash_activate_script, compatibility): ++ script = bash_activate_script(prompt="a\\b$c`d") ++ result = subprocess.run( ++ [ ++ _BASH, ++ "--noprofile", ++ "--norc", ++ "-c", ++ f'BASH_COMPAT={compatibility}; PS1=__END__; . "$1"; printf "%s" "$PS1"', ++ "test", ++ str(script), ++ ], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ ++ assert result.stdout == "(a\\\\\\\\b\\$c\\`d) __END__" ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("prompt", ["x$(printf EXPANDED)y", "x`printf EXPANDED`y", r"x\$(printf EXPANDED)y"]) ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++@pytest.mark.parametrize("promptvars", [True, False], ids=["promptvars-on", "promptvars-off"]) ++@pytest.mark.parametrize( ++ ("after", "expanded"), ++ [ ++ pytest.param("shopt -s promptvars", True, id="enable-promptvars"), ++ pytest.param("set -o posix", True, id="enable-posix"), ++ pytest.param("shopt -u promptvars", False, id="disable-promptvars"), ++ ], ++) ++def test_bash_prompt_state_changes( # noqa: PLR0913 ++ tmp_path, bash_activate_script, prompt, compatibility, promptvars, after, expanded ++): ++ script = bash_activate_script(prompt=prompt) ++ before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" ++ ++ rendered = _interactive_prompt(script, tmp_path, before=before, after=after) ++ ++ assert _last_prompt(rendered) == f"({_expected_bash_prompt(prompt, expanded)}) __END__" ++ ++ ++@pytest.mark.skipif(IS_WIN or _BASH is None, reason="requires POSIX bash") ++@pytest.mark.parametrize("prompt", ["x$(printf EXPANDED)y", "x`printf EXPANDED`y", r"x\$(printf EXPANDED)y"]) ++@pytest.mark.parametrize("compatibility", ["31", "42", "51"], ids=["bash31", "bash42", "bash51"]) ++@pytest.mark.parametrize("promptvars", [True, False], ids=["promptvars-on", "promptvars-off"]) ++def test_bash_child_shell_keeps_prompt_literal(tmp_path, bash_activate_script, prompt, compatibility, promptvars): ++ script = bash_activate_script(prompt=prompt) ++ before = f"BASH_COMPAT={compatibility}\nshopt {'-s' if promptvars else '-u'} promptvars" ++ after = ( ++ f"{shlex.quote(_BASH)} --noprofile --norc -i 2> child-prompt.txt <<'VIRTUALENV_CHILD'\nexit\nVIRTUALENV_CHILD" ++ ) ++ ++ _interactive_prompt(script, tmp_path, before=before, after=after) ++ ++ rendered = (tmp_path / "child-prompt.txt").read_text(encoding="utf-8") ++ assert _last_prompt(rendered) == f"({_expected_bash_prompt(prompt)}) __END__" ++ ++ ++@pytest.mark.skipif(IS_WIN or shutil.which("zsh") is None, reason="requires POSIX zsh") ++def test_zsh_deactivate_restores_psvar(tmp_path, bash_activate_script): ++ script = bash_activate_script(prompt="plain") ++ result = subprocess.run( ++ [ ++ shutil.which("zsh"), ++ "-f", ++ "-c", ++ 'psvar[9]=original; . "$1"; print -r -- "$psvar[9]"; ' ++ 'deactivate; print -r -- "$psvar[9]"; print -r -- "${_OLD_VIRTUAL_PSVAR-unset}"', ++ "test", ++ str(script), ++ ], ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ ++ assert result.stdout == "plain\noriginal\nunset\n" + + + @pytest.mark.skipif(IS_WIN, reason="requires POSIX shells") +@@ -321,10 +423,9 @@ + return super().activate_call(script) + " || exit 1" + + def print_prompt(self): +- return ( +- "case \"$PS1\" in '(${VIRTUAL_ENV_PROMPT-}) '*) " +- "printf '(%s) %s\\n' \"$VIRTUAL_ENV_PROMPT\" \"${PS1#'(${VIRTUAL_ENV_PROMPT-}) '}\";; " +- "*) printf '%s\\n' \"$PS1\";; esac" +- ) ++ return 'printf "exit\\n" | PS1="$PS1" bash --noprofile --norc -i 2>&1 >/dev/null' ++ ++ def expected_prompt(self): ++ return f"({_expected_bash_prompt(self._creator.env_name)}) " + + activation_tester(Bash) +diff --git a/tests/unit/activation/conftest.py b/tests/unit/activation/conftest.py +index 53a819f..aa58273 100644 +--- a/tests/unit/activation/conftest.py ++++ b/tests/unit/activation/conftest.py +@@ -141,7 +141,7 @@ + assert out[5] == self._creator.env_name + # Some attempts to test the prompt output print more than 1 line. + # So we need to check if the prompt exists on any of them. +- prompt_text = f"({self._creator.env_name}) " ++ prompt_text = self.expected_prompt() + assert any(prompt_text in line for line in out[6:-4]), raw + + assert out[-4] == "wrote pydoc_test.html", raw +@@ -167,6 +167,9 @@ + + def print_prompt(self): + return NotImplemented ++ ++ def expected_prompt(self): ++ return f"({self._creator.env_name}) " + + def activate_call(self, script): + cmd = self.quote(str(self.activate_cmd)) +diff --git a/docs/changelog/3466.bugfix.rst b/docs/changelog/3466.bugfix.rst +new file mode 100644 +index 0000000..7cb71b7 +--- /dev/null ++++ b/docs/changelog/3466.bugfix.rst +@@ -0,0 +1,6 @@ ++Escape the Bash activation prompt independently of current prompt options, including later changes to ``promptvars``, ++POSIX mode, and child shells inheriting ``PS1`` (CVE-2026-115586). Zsh inserts the name through ``psvar[9]`` as data and ++restores its previous value on deactivation. ++ ++With ``promptvars`` disabled, Bash displays doubled backslashes and a leading backslash before backticks. Under root, ++its dollar-sign prompt escape displays ``#``. These display differences preserve escaping for later option changes. +-- +2.45.4 diff --git a/SPECS/python-virtualenv/python-virtualenv.spec b/SPECS/python-virtualenv/python-virtualenv.spec index a2297e63886..b8a072abd1a 100644 --- a/SPECS/python-virtualenv/python-virtualenv.spec +++ b/SPECS/python-virtualenv/python-virtualenv.spec @@ -1,7 +1,7 @@ Summary: Virtual Python Environment builder Name: python-virtualenv Version: 20.36.1 -Release: 9%{?dist} +Release: 10%{?dist} License: MIT Vendor: Microsoft Corporation Distribution: Azure Linux @@ -25,6 +25,21 @@ Patch1012: CVE-2026-13346v1.patch Patch1013: CVE-2026-102925.patch Patch1014: CVE-2026-102930.patch Patch1015: remove-compromised-wheel-ca-roots.patch +# Keep these backports in dependency order rather than CVE-number order. +Patch1016: CVE-2026-113732.patch +Patch1017: CVE-2026-113716.patch +Patch1018: CVE-2026-113723.patch +Patch1019: CVE-2026-113726.patch +Patch1020: CVE-2026-113713.patch +Patch1021: CVE-2026-113776.patch +Patch1022: CVE-2026-113715.patch +Patch1023: CVE-2026-113722.patch +Patch1024: CVE-2026-113725.patch +Patch1025: CVE-2026-113720.patch +Patch1026: CVE-2026-113719.patch +Patch1027: CVE-2026-113721.patch +Patch1028: CVE-2026-102938.patch +Patch1029: CVE-2026-115586.patch BuildArch: noarch %description @@ -60,6 +75,20 @@ virtualenv is a tool to create isolated Python environment. %patch -P 1013 -p1 %patch -P 1014 -p1 %patch -P 1015 -p1 +%patch -P 1016 -p1 +%patch -P 1017 -p1 +%patch -P 1018 -p1 +%patch -P 1019 -p1 +%patch -P 1020 -p1 +%patch -P 1021 -p1 +%patch -P 1022 -p1 +%patch -P 1023 -p1 +%patch -P 1024 -p1 +%patch -P 1025 -p1 +%patch -P 1026 -p1 +%patch -P 1027 -p1 +%patch -P 1028 -p1 +%patch -P 1029 -p1 # Manual patching for CVE-2025-50181 and CVE-2026-1703v0 # For CVE-2025-50181, poolmanager.py file is located in 2 different places and each is of different version so the same patch cannot be applied to all of them. @@ -188,10 +217,16 @@ tox -e py %{_bindir}/virtualenv %changelog -* Sat Oct 31 2026 Kanishk Bansal - 20.36.1-9 +* Sat Oct 31 2026 Kanishk Bansal - 20.36.1-10 - Reconcile the published CA cleanup with the existing tox-based ptest bootstrap. - Pin packaging==23.2 when installing test tooling to preserve RPM-owned packaging. +* Tue Oct 27 2026 Durga Jagadeesh Palli - 20.36.1-9 +- Patch for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, and CVE-2026-113716. +- Patch for CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, and CVE-2026-113722. +- Patch for CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, and CVE-2026-113732. +- Patch for CVE-2026-113776 and CVE-2026-115586. + * Fri Oct 23 2026 Jon Slobodzian - 20.36.1-8 - Remove affected eMudhra and AAA roots from both embedded pip wheels. - Regenerate wheel RECORD entries after the existing security backports.