Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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