Skip to content
Open
90 changes: 90 additions & 0 deletions SPECS/python-virtualenv/CVE-2026-102938.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
From a30f995461043acb6cacbf3a890951563ccf7140 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
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
109 changes: 109 additions & 0 deletions SPECS/python-virtualenv/CVE-2026-113713.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
From b81995c3feab9e1edb453c75ffc6616e1a77335e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
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

192 changes: 192 additions & 0 deletions SPECS/python-virtualenv/CVE-2026-113715.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
From b0008a58885ded9227faf8f910c6c402712c750a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
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

Loading
Loading