From 02116a1ae0fac8cbccdb451eb743dc650b996786 Mon Sep 17 00:00:00 2001 From: jslobodzian Date: Sat, 24 Oct 2026 23:45:34 -0400 Subject: [PATCH 1/5] python-virtualenv: remove affected roots from embedded pip wheels (#19710) Co-authored-by: Jon Slobodzian Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e62137f2-2602-418d-ba0d-3b1f2ff0259e (cherry picked from commit 78a1a1873c10791d520bf6c078abd0f092a9725d) --- .../python-virtualenv/python-virtualenv.spec | 50 ++++++++ .../remove-compromised-wheel-ca-roots.patch | 119 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 SPECS/python-virtualenv/remove-compromised-wheel-ca-roots.patch diff --git a/SPECS/python-virtualenv/python-virtualenv.spec b/SPECS/python-virtualenv/python-virtualenv.spec index bf328bd0df1..b4e9323e45e 100644 --- a/SPECS/python-virtualenv/python-virtualenv.spec +++ b/SPECS/python-virtualenv/python-virtualenv.spec @@ -24,6 +24,7 @@ Patch1011: CVE-2026-13346v0.patch Patch1012: CVE-2026-13346v1.patch Patch1013: CVE-2026-102925.patch Patch1014: CVE-2026-102930.patch +Patch1015: remove-compromised-wheel-ca-roots.patch BuildArch: noarch %description @@ -58,6 +59,7 @@ virtualenv is a tool to create isolated Python environment. %patch -P 0 -p1 %patch -P 1013 -p1 %patch -P 1014 -p1 +%patch -P 1015 -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. @@ -158,6 +160,9 @@ zip -r ../src/virtualenv/seed/wheels/embed/unpacked_wheel-0.45.1-py3-none-any.wh popd rm -rf unpacked_wheel-0.45.1-py3-none-any +%{__python3} Tools/remove-untrusted-wheel-roots.py \ + --expected-removals 3 src/virtualenv/seed/wheels/embed/pip-*.whl + %generate_buildrequires %build @@ -167,6 +172,7 @@ rm -rf unpacked_wheel-0.45.1-py3-none-any %pyproject_install %check +<<<<<<< HEAD # Pin packaging to the RPM-installed version so pip does not try to # upgrade it for virtualenv >= 21.14.6 (requires packaging >= 26.3); the # rpm-installed copy has no RECORD file, so pip aborts with @@ -175,6 +181,43 @@ pip3 install packaging==23.2 'tox>=3.27.1,<4.0.0' # skip "test_can_build_c_extensions" tests since they fail on python3_version >= 3.12. See https://src.fedoraproject.org/rpms/python-virtualenv/blob/rawhide/f/python-virtualenv.spec#_153 sed -i 's/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests --int}/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests -k "not test_can_build_c_extensions" --int}/g' tox.ini tox -e py +======= +# Keep the test environment outside zipapp's source copy. +check_env=$(mktemp -d "$PWD/../virtualenv-check.XXXXXX") +python3 -m venv "$check_env" +cat > "$check_env/constraints.txt" <<'EOF' +covdefaults==2.3.0 +coverage==7.10.6 +coverage-enable-subprocess==1.0 +distlib==0.3.8 +filelock==3.20.3 +flaky==3.8.1 +iniconfig==2.1.0 +packaging==25.0 +platformdirs==4.3.8 +pluggy==1.6.0 +pygments==2.19.2 +pytest==8.4.2 +pytest-env==1.1.5 +pytest-mock==3.15.1 +pytest-randomly==3.16.0 +pytest-timeout==2.4.0 +python-dateutil==2.9.0.post0 +setuptools==80.9.0 +six==1.17.0 +time-machine==2.19.0 +typing-extensions==4.13.2 +EOF +set -- %{_pyproject_wheeldir}/*.whl +test "$#" -eq 1 +"$check_env/bin/python" -m pip install --disable-pip-version-check \ + --constraint "$check_env/constraints.txt" "$1[test]" +export PATH="$check_env/bin:$PATH" +unset COVERAGE_PROCESS_START +export PYTHONWARNDEFAULTENCODING=1 +"$check_env/bin/python" -m pytest --junitxml junit.py.xml \ + tests -k 'not test_can_build_c_extensions' --int +>>>>>>> 78a1a1873c (python-virtualenv: remove affected roots from embedded pip wheels (#19710)) %files -n python3-virtualenv %defattr(-,root,root,-) @@ -183,9 +226,16 @@ tox -e py %{_bindir}/virtualenv %changelog +<<<<<<< HEAD * Mon Oct 12 2026 Kanishk Bansal - 20.36.1-8 - Pin packaging==23.2 when pip-installing test tooling in %%check to fix ptest failure (uninstall-no-record-file) caused by virtualenv 21.14.6. +======= +* 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. +- Run pytest directly with pinned test dependencies in a private environment. +>>>>>>> 78a1a1873c (python-virtualenv: remove affected roots from embedded pip wheels (#19710)) * Mon Oct 05 2026 Kshitiz Godara - 20.36.1-7 - Patch for CVE-2026-102925 and CVE-2026-102930 diff --git a/SPECS/python-virtualenv/remove-compromised-wheel-ca-roots.patch b/SPECS/python-virtualenv/remove-compromised-wheel-ca-roots.patch new file mode 100644 index 00000000000..3eeaa9c7d77 --- /dev/null +++ b/SPECS/python-virtualenv/remove-compromised-wheel-ca-roots.patch @@ -0,0 +1,119 @@ +Subject: [PATCH] Distrust incident-related eMudhra and AAA trust anchors + +Remove only the affected trust anchors, including the AAA cross-signing +path. Preserve the unaffected emSign G1, C1 and C3 anchors. +Reference: https://emudhra.we-have-a-problem.com/ +Upstream-status: Azure Linux trust-store backport + +--- /dev/null ++++ b/Tools/remove-untrusted-wheel-roots.py +@@ -0,0 +1,109 @@ ++"""Remove incident-related trust anchors from bundled wheels and renew RECORD.""" ++ ++import argparse ++import base64 ++import csv ++import hashlib ++import io ++from pathlib import Path ++import re ++import ssl ++import tempfile ++import zipfile ++ ++ ++UNTRUSTED_SHA256 = { ++ "d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4", ++ "86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b", ++} ++CERTIFICATE = re.compile( ++ rb"-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+?-----END CERTIFICATE-----" ++) ++ ++ ++def prepare_wheel(path): ++ with zipfile.ZipFile(path) as archive: ++ infos = archive.infolist() ++ if len({info.filename for info in infos}) != len(infos): ++ raise ValueError(f"{path}: duplicate wheel members") ++ records = [ ++ info.filename ++ for info in infos ++ if info.filename.endswith(".dist-info/RECORD") ++ ] ++ if len(records) != 1: ++ raise ValueError(f"{path}: expected exactly one wheel RECORD") ++ record = records[0] ++ if any( ++ name in archive.namelist() for name in (record + ".jws", record + ".p7s") ++ ): ++ raise ValueError(f"{path}: cannot rewrite a signed wheel") ++ payload = {info.filename: archive.read(info) for info in infos} ++ ++ removed = 0 ++ ++ def retain_certificate(match): ++ nonlocal removed ++ der = ssl.PEM_cert_to_DER_cert(match.group().decode("ascii")) ++ if hashlib.sha256(der).hexdigest() in UNTRUSTED_SHA256: ++ removed += 1 ++ return b"" ++ return match.group() ++ ++ for name, data in payload.items(): ++ if name.endswith((".pem", ".crt")): ++ payload[name] = CERTIFICATE.sub(retain_certificate, data) ++ ++ if not removed: ++ return None, 0 ++ ++ output = io.StringIO(newline="") ++ writer = csv.writer(output, lineterminator="\n") ++ for info in infos: ++ name = info.filename ++ if info.is_dir(): ++ continue ++ if name == record: ++ writer.writerow((name, "", "")) ++ else: ++ data = payload[name] ++ digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()) ++ writer.writerow((name, "sha256=" + digest.rstrip(b"=").decode(), len(data))) ++ payload[record] = output.getvalue().encode("utf-8") ++ return (path, infos, payload), removed ++ ++ ++def main(): ++ parser = argparse.ArgumentParser(description=__doc__) ++ parser.add_argument("--expected-removals", type=int, required=True) ++ parser.add_argument("wheels", nargs="+", type=Path) ++ args = parser.parse_args() ++ plans = [] ++ total = 0 ++ for path in args.wheels: ++ plan, removed = prepare_wheel(path) ++ if plan is not None: ++ plans.append(plan) ++ total += removed ++ print(f"{path}: removing {removed} affected trust anchors") ++ if total != args.expected_removals: ++ raise ValueError( ++ f"Expected {args.expected_removals} trust anchors, found {total}; " ++ "review the bundled wheels before changing this expectation" ++ ) ++ for path, infos, payload in plans: ++ with tempfile.NamedTemporaryFile( ++ dir=path.parent, prefix=path.name + ".", suffix=".tmp", delete=False ++ ) as temporary: ++ temporary_path = Path(temporary.name) ++ try: ++ with zipfile.ZipFile(temporary_path, "w") as archive: ++ for info in infos: ++ archive.writestr(info, payload[info.filename]) ++ temporary_path.replace(path) ++ finally: ++ temporary_path.unlink(missing_ok=True) ++ ++ ++if __name__ == "__main__": ++ main() From 23e1d6be971c87682e8823fc91dafdd101055d94 Mon Sep 17 00:00:00 2001 From: "azurelinux-ci-jwt-app[bot]" <293883219+azurelinux-ci-jwt-app[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:50:43 +0000 Subject: [PATCH 2/5] Conflicts resolved by Auto-Cherry Pick for SPECS/python-virtualenv/python-virtualenv.spec --- .../python-virtualenv/python-virtualenv.spec | 56 ++++++++++++++++--- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/SPECS/python-virtualenv/python-virtualenv.spec b/SPECS/python-virtualenv/python-virtualenv.spec index b4e9323e45e..4b8ccf24f14 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: 8%{?dist} +Release: 9%{?dist} License: MIT Vendor: Microsoft Corporation Distribution: Azure Linux @@ -172,10 +172,49 @@ rm -rf unpacked_wheel-0.45.1-py3-none-any %pyproject_install %check -<<<<<<< HEAD -# Pin packaging to the RPM-installed version so pip does not try to +# Pin packaging to the RPM-installed version so pip does not # upgrade it for virtualenv >= 21.14.6 (requires packaging >= 26.3); the # rpm-installed copy has no RECORD file, so pip aborts with + +# skip "test_can_build_c_extensions" tests since they fail on python3_version >= 3.12. See https://src.fedoraproject.org/rpms/python-virtualenv/blob/rawhide/f/python-virtualenv.spec#_153 +sed -i 's/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests --int}/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests -k "not test_can_build_c_extensions" --int}/g' tox.ini +tox -e py + +# Keep the test environment outside zipapp's source copy. +check_env=$(mktemp -d "$PWD/../virtualenv-check.XXXXXX") +python3 -m venv "$check_env" +cat > "$check_env/constraints.txt" <<'EOF' +covdefaults==2.3.0 +coverage==7.10.6 +coverage-enable-subprocess==1.0 +distlib==0.3.8 +filelock==3.20.3 +flaky==3.8.1 +iniconfig==2.1.0 +packaging==25.0 +platformdirs==4.3.8 +pluggy==1.6.0 +pygments==2.19.2 +pytest==8.4.2 +pytest-env==1.1.5 +pytest-mock==3.15.1 +pytest-randomly==3.16.0 +pytest-timeout==2.4.0 +python-dateutil==2.9.0.post0 +setuptools==80.9.0 +six==1.17.0 +time-machine==2.19.0 +typing-extensions==4.13.2 +EOF +set -- %{_pyproject_wheeldir}/*.whl +test "$#" -eq 1 +"$check_env/bin/python" -m pip install --disable-pip-version-check \ + --constraint "$check_env/constraints.txt" "$1[test]" +export PATH="$check_env/bin:$PATH" +unset COVERAGE_PROCESS_START +export PYTHONWARNDEFAULTENCODING=1 +"$check_env/bin/python" -m pytest --junitxml junit.py.xml \ + tests -k 'not test_can_build_c_extensions' --int # uninstall-no-record-file and the test tooling is never installed. pip3 install packaging==23.2 'tox>=3.27.1,<4.0.0' # skip "test_can_build_c_extensions" tests since they fail on python3_version >= 3.12. See https://src.fedoraproject.org/rpms/python-virtualenv/blob/rawhide/f/python-virtualenv.spec#_153 @@ -226,16 +265,14 @@ export PYTHONWARNDEFAULTENCODING=1 %{_bindir}/virtualenv %changelog -<<<<<<< HEAD -* Mon Oct 12 2026 Kanishk Bansal - 20.36.1-8 -- Pin packaging==23.2 when pip-installing test tooling in %%check to fix +* Sun Oct 25 2026 Kanishk Bansal - 20.36.1-9 +- Pin packaging==23.2 when pip-installing test tooling in %check to fix ptest failure (uninstall-no-record-file) caused by virtualenv 21.14.6. -======= + * 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. - Run pytest directly with pinned test dependencies in a private environment. ->>>>>>> 78a1a1873c (python-virtualenv: remove affected roots from embedded pip wheels (#19710)) * Mon Oct 05 2026 Kshitiz Godara - 20.36.1-7 - Patch for CVE-2026-102925 and CVE-2026-102930 @@ -243,6 +280,9 @@ export PYTHONWARNDEFAULTENCODING=1 * Mon Aug 03 2026 Ratiranjan Behera - 20.36.1-6 - Patch for CVE-2026-13346 +* Mon Aug 03 2026 Ratiranjan Behera - 20.36.1-6 +- Patch for CVE-2026-13346 + * Wed Jun 17 2026 Aditya Singh - 20.36.1-5 - Patch for CVE-2026-8643 From 05c8cc7acd83421750ee00e1ee181f4a7b1f08e0 Mon Sep 17 00:00:00 2001 From: durgajagadeesh Date: Fri, 30 Oct 2026 01:24:34 +0530 Subject: [PATCH 3/5] [High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 (#19798) (cherry picked from commit 88b378a0cb0572bce86de17ba9000eafa4860450) --- SPECS/python-virtualenv/CVE-2026-102938.patch | 90 +++++ SPECS/python-virtualenv/CVE-2026-113713.patch | 109 +++++ SPECS/python-virtualenv/CVE-2026-113715.patch | 192 +++++++++ SPECS/python-virtualenv/CVE-2026-113716.patch | 334 +++++++++++++++ SPECS/python-virtualenv/CVE-2026-113719.patch | 187 +++++++++ SPECS/python-virtualenv/CVE-2026-113720.patch | 232 +++++++++++ SPECS/python-virtualenv/CVE-2026-113721.patch | 381 ++++++++++++++++++ SPECS/python-virtualenv/CVE-2026-113722.patch | 251 ++++++++++++ SPECS/python-virtualenv/CVE-2026-113723.patch | 251 ++++++++++++ SPECS/python-virtualenv/CVE-2026-113725.patch | 55 +++ SPECS/python-virtualenv/CVE-2026-113726.patch | 115 ++++++ SPECS/python-virtualenv/CVE-2026-113732.patch | 112 +++++ SPECS/python-virtualenv/CVE-2026-113776.patch | 99 +++++ SPECS/python-virtualenv/CVE-2026-115586.patch | 325 +++++++++++++++ .../python-virtualenv/python-virtualenv.spec | 52 ++- 15 files changed, 2784 insertions(+), 1 deletion(-) create mode 100644 SPECS/python-virtualenv/CVE-2026-102938.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113713.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113715.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113716.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113719.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113720.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113721.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113722.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113723.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113725.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113726.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113732.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-113776.patch create mode 100644 SPECS/python-virtualenv/CVE-2026-115586.patch 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 bf328bd0df1..dc55d2569eb 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: 8%{?dist} +Release: 9%{?dist} License: MIT Vendor: Microsoft Corporation Distribution: Azure Linux @@ -24,6 +24,25 @@ Patch1011: CVE-2026-13346v0.patch Patch1012: CVE-2026-13346v1.patch Patch1013: CVE-2026-102925.patch Patch1014: CVE-2026-102930.patch +<<<<<<< HEAD +======= +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 +>>>>>>> 88b378a0cb ([High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 (#19798)) BuildArch: noarch %description @@ -58,6 +77,24 @@ virtualenv is a tool to create isolated Python environment. %patch -P 0 -p1 %patch -P 1013 -p1 %patch -P 1014 -p1 +<<<<<<< HEAD +======= +%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 +>>>>>>> 88b378a0cb ([High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 (#19798)) # 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. @@ -183,9 +220,22 @@ tox -e py %{_bindir}/virtualenv %changelog +<<<<<<< HEAD * Mon Oct 12 2026 Kanishk Bansal - 20.36.1-8 - Pin packaging==23.2 when pip-installing test tooling in %%check to fix ptest failure (uninstall-no-record-file) caused by virtualenv 21.14.6. +======= +* 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. +- Run pytest directly with pinned test dependencies in a private environment. +>>>>>>> 88b378a0cb ([High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 (#19798)) * Mon Oct 05 2026 Kshitiz Godara - 20.36.1-7 - Patch for CVE-2026-102925 and CVE-2026-102930 From 63f8fcc2a58c6ca497675a20e5354814f5851aca Mon Sep 17 00:00:00 2001 From: "azurelinux-ci-jwt-app[bot]" <293883219+azurelinux-ci-jwt-app[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:00:12 +0000 Subject: [PATCH 4/5] Conflicts resolved by Auto-Cherry Pick for SPECS/python-virtualenv/python-virtualenv.spec --- .../python-virtualenv/python-virtualenv.spec | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/SPECS/python-virtualenv/python-virtualenv.spec b/SPECS/python-virtualenv/python-virtualenv.spec index dc55d2569eb..d7f6a61daef 100644 --- a/SPECS/python-virtualenv/python-virtualenv.spec +++ b/SPECS/python-virtualenv/python-virtualenv.spec @@ -24,10 +24,7 @@ Patch1011: CVE-2026-13346v0.patch Patch1012: CVE-2026-13346v1.patch Patch1013: CVE-2026-102925.patch Patch1014: CVE-2026-102930.patch -<<<<<<< HEAD -======= 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 @@ -42,10 +39,6 @@ Patch1026: CVE-2026-113719.patch Patch1027: CVE-2026-113721.patch Patch1028: CVE-2026-102938.patch Patch1029: CVE-2026-115586.patch ->>>>>>> 88b378a0cb ([High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 (#19798)) -BuildArch: noarch - -%description virtualenv is a tool to create isolated Python environment. %package -n python3-virtualenv @@ -77,8 +70,6 @@ virtualenv is a tool to create isolated Python environment. %patch -P 0 -p1 %patch -P 1013 -p1 %patch -P 1014 -p1 -<<<<<<< HEAD -======= %patch -P 1015 -p1 %patch -P 1016 -p1 %patch -P 1017 -p1 @@ -94,10 +85,6 @@ virtualenv is a tool to create isolated Python environment. %patch -P 1027 -p1 %patch -P 1028 -p1 %patch -P 1029 -p1 ->>>>>>> 88b378a0cb ([High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 (#19798)) - -# 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. # For CVE-2026-1703, unpacking.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. # Affected files are under src and archived inside a .whl file, so we need to unpack it, apply the patch, and then re-zip it. @@ -220,13 +207,12 @@ tox -e py %{_bindir}/virtualenv %changelog -<<<<<<< HEAD -* Mon Oct 12 2026 Kanishk Bansal - 20.36.1-8 +* Thu Oct 29 2026 Kanishk Bansal - 20.36.1-10 - Pin packaging==23.2 when pip-installing test tooling in %%check to fix ptest failure (uninstall-no-record-file) caused by virtualenv 21.14.6. -======= + * 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-102938, CVE-2026-113713, 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. @@ -235,7 +221,6 @@ tox -e py - Remove affected eMudhra and AAA roots from both embedded pip wheels. - Regenerate wheel RECORD entries after the existing security backports. - Run pytest directly with pinned test dependencies in a private environment. ->>>>>>> 88b378a0cb ([High] Patch python-virtualenv for CVE-2026-102938, CVE-2026-113713, CVE-2026-113715, CVE-2026-113716, CVE-2026-113719, CVE-2026-113720, CVE-2026-113721, CVE-2026-113722, CVE-2026-113723, CVE-2026-113725, CVE-2026-113726, CVE-2026-113732, CVE-2026-113776 (#19798)) * Mon Oct 05 2026 Kshitiz Godara - 20.36.1-7 - Patch for CVE-2026-102925 and CVE-2026-102930 @@ -243,6 +228,9 @@ tox -e py * Mon Aug 03 2026 Ratiranjan Behera - 20.36.1-6 - Patch for CVE-2026-13346 +* Mon Aug 03 2026 Ratiranjan Behera - 20.36.1-6 +- Patch for CVE-2026-13346 + * Wed Jun 17 2026 Aditya Singh - 20.36.1-5 - Patch for CVE-2026-8643 From b3ce67655b0997badd39ca73e6920b5733049430 Mon Sep 17 00:00:00 2001 From: jslobodzian Date: Fri, 30 Oct 2026 13:11:54 -0400 Subject: [PATCH 5/5] fix: repair older virtualenv CA cherry-pick and isolated ptest setup Preserve the published CA patch, wheel helper invocation, and private pinned pytest environment. Remove committed conflict markers, duplicate test flows and duplicate changelog entries. Resequence the dev RPM-owned-packaging fix as release 9. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ed20da6-df62-47ec-8bba-6847e07d8f43 --- .../python-virtualenv/python-virtualenv.spec | 60 ++----------------- 1 file changed, 4 insertions(+), 56 deletions(-) diff --git a/SPECS/python-virtualenv/python-virtualenv.spec b/SPECS/python-virtualenv/python-virtualenv.spec index 4b8ccf24f14..ab0ac7b8bb1 100644 --- a/SPECS/python-virtualenv/python-virtualenv.spec +++ b/SPECS/python-virtualenv/python-virtualenv.spec @@ -172,55 +172,6 @@ rm -rf unpacked_wheel-0.45.1-py3-none-any %pyproject_install %check -# Pin packaging to the RPM-installed version so pip does not -# upgrade it for virtualenv >= 21.14.6 (requires packaging >= 26.3); the -# rpm-installed copy has no RECORD file, so pip aborts with - -# skip "test_can_build_c_extensions" tests since they fail on python3_version >= 3.12. See https://src.fedoraproject.org/rpms/python-virtualenv/blob/rawhide/f/python-virtualenv.spec#_153 -sed -i 's/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests --int}/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests -k "not test_can_build_c_extensions" --int}/g' tox.ini -tox -e py - -# Keep the test environment outside zipapp's source copy. -check_env=$(mktemp -d "$PWD/../virtualenv-check.XXXXXX") -python3 -m venv "$check_env" -cat > "$check_env/constraints.txt" <<'EOF' -covdefaults==2.3.0 -coverage==7.10.6 -coverage-enable-subprocess==1.0 -distlib==0.3.8 -filelock==3.20.3 -flaky==3.8.1 -iniconfig==2.1.0 -packaging==25.0 -platformdirs==4.3.8 -pluggy==1.6.0 -pygments==2.19.2 -pytest==8.4.2 -pytest-env==1.1.5 -pytest-mock==3.15.1 -pytest-randomly==3.16.0 -pytest-timeout==2.4.0 -python-dateutil==2.9.0.post0 -setuptools==80.9.0 -six==1.17.0 -time-machine==2.19.0 -typing-extensions==4.13.2 -EOF -set -- %{_pyproject_wheeldir}/*.whl -test "$#" -eq 1 -"$check_env/bin/python" -m pip install --disable-pip-version-check \ - --constraint "$check_env/constraints.txt" "$1[test]" -export PATH="$check_env/bin:$PATH" -unset COVERAGE_PROCESS_START -export PYTHONWARNDEFAULTENCODING=1 -"$check_env/bin/python" -m pytest --junitxml junit.py.xml \ - tests -k 'not test_can_build_c_extensions' --int -# uninstall-no-record-file and the test tooling is never installed. -pip3 install packaging==23.2 'tox>=3.27.1,<4.0.0' -# skip "test_can_build_c_extensions" tests since they fail on python3_version >= 3.12. See https://src.fedoraproject.org/rpms/python-virtualenv/blob/rawhide/f/python-virtualenv.spec#_153 -sed -i 's/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests --int}/coverage run -m pytest {posargs:--junitxml {toxworkdir}\/junit\.{envname}\.xml tests -k "not test_can_build_c_extensions" --int}/g' tox.ini -tox -e py -======= # Keep the test environment outside zipapp's source copy. check_env=$(mktemp -d "$PWD/../virtualenv-check.XXXXXX") python3 -m venv "$check_env" @@ -256,7 +207,6 @@ unset COVERAGE_PROCESS_START export PYTHONWARNDEFAULTENCODING=1 "$check_env/bin/python" -m pytest --junitxml junit.py.xml \ tests -k 'not test_can_build_c_extensions' --int ->>>>>>> 78a1a1873c (python-virtualenv: remove affected roots from embedded pip wheels (#19710)) %files -n python3-virtualenv %defattr(-,root,root,-) @@ -265,9 +215,10 @@ export PYTHONWARNDEFAULTENCODING=1 %{_bindir}/virtualenv %changelog -* Sun Oct 25 2026 Kanishk Bansal - 20.36.1-9 -- Pin packaging==23.2 when pip-installing test tooling in %check to fix - ptest failure (uninstall-no-record-file) caused by virtualenv 21.14.6. +* Fri Oct 30 2026 Kanishk Bansal - 20.36.1-9 +- Preserve the dev fix for RPM-owned packaging during ptest bootstrap. +- Use the published private, pinned test environment in place of the + system-pip packaging==23.2/tox bootstrap. * Fri Oct 23 2026 Jon Slobodzian - 20.36.1-8 - Remove affected eMudhra and AAA roots from both embedded pip wheels. @@ -280,9 +231,6 @@ export PYTHONWARNDEFAULTENCODING=1 * Mon Aug 03 2026 Ratiranjan Behera - 20.36.1-6 - Patch for CVE-2026-13346 -* Mon Aug 03 2026 Ratiranjan Behera - 20.36.1-6 -- Patch for CVE-2026-13346 - * Wed Jun 17 2026 Aditya Singh - 20.36.1-5 - Patch for CVE-2026-8643