From 8d74b1295e2983b35dd39de47f7317ca6082b571 Mon Sep 17 00:00:00 2001 From: Hetvi Thakar Date: Thu, 5 Nov 2026 00:59:57 -0800 Subject: [PATCH] python3-virtualenv: fix CVE-2026-113723 Backport the upstream prompt-handling fix [1] identified by advisory [3]. Preserve literal prompts across Bash compatibility modes and leave PS1 unchanged in unsupported POSIX shells. Include follow-up [2], identified by advisory [4], so later prompt option changes and child shells cannot execute the stored prompt. This also fixes CVE-2026-115586, a regression in the initial fix. Keep only the required Bash/Zsh security changes and regression tests. [1] https://github.com/pypa/virtualenv/commit/5f48d711731311428aebfae61d46957762155b22 [2] https://github.com/pypa/virtualenv/commit/3598e201f6f044e389136b2347a8a919ac03c5f4 [3] https://github.com/pypa/virtualenv/security/advisories/GHSA-6w3r-xr62-4xcp [4] https://github.com/pypa/virtualenv/security/advisories/GHSA-cx6f-vjhg-g3cc Signed-off-by: Hetvi Thakar Signed-off-by: Anuj Mittal --- .../python3-virtualenv/CVE-2026-113723.patch | 233 ++++++++ .../python3-virtualenv/CVE-2026-115586.patch | 526 ++++++++++++++++++ .../python/python3-virtualenv_21.2.1.bb | 2 + 3 files changed, 761 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113723.patch create mode 100644 meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-115586.patch diff --git a/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113723.patch b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113723.patch new file mode 100644 index 0000000000..661d50e060 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-113723.patch @@ -0,0 +1,233 @@ +From 5f48d711731311428aebfae61d46957762155b22 Mon Sep 17 00:00:00 2001 +From: Bernát Gábor +Date: Sat, 17 Oct 2026 02:35:17 -0700 +Subject: [PATCH] fix(bash): preserve literal prompts in supported shells + (#3418) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-113723 +Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/5f48d711731311428aebfae61d46957762155b22] + +Backport Changes: +- Apply the prompt changes to 21.2.1's top-level activation block, + without importing the newer relocation wrapper; retain its PS1 export. +- Add the missing Final import and replace the older print_prompt() + test helper with the upstream updated renderer. +- Omit the upstream release changelog fragment. + +Signed-off-by: Hetvi Thakar +--- + src/virtualenv/activation/bash/activate.sh | 39 ++++---- + tests/unit/activation/test_bash.py | 105 +++++++++++++++++++-- + 2 files changed, 115 insertions(+), 29 deletions(-) + +diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh +index 1551d85..992ad94 100644 +--- a/src/virtualenv/activation/bash/activate.sh ++++ b/src/virtualenv/activation/bash/activate.sh +@@ -113,29 +113,26 @@ if [ __TK_LIBRARY__ != "" ]; then + 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-}" +- _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT}" +- # bash, and zsh under PROMPT_SUBST, re-expand backslashes, dollar signs and backticks in PS1 on every redraw; +- # zsh gets the escaping even without the option, since a user can set it after activation. The guards keep +- # the substitution away from POSIX shells such as dash, which reject it with "Bad substitution" +- if [ -n "${BASH_VERSION-}" ]; then +- # bash first decodes its backslash prompt escapes, turning \\ into \ and \$ into $ (or # for root), and only +- # then expands the result, so each character needs escaping for both passes +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\\/\\\\\\\\}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\\\$}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\\\`}" +- elif [ -n "${ZSH_VERSION-}" ]; then +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\\/\\\\}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}" ++ # bash expands PS1 after decoding its prompt escapes when promptvars is on or in posix mode, so the prompt holds a ++ # reference to the variable and its value never reaches that expansion; otherwise only the decode runs and ++ # backslashes need doubling. zsh re-expands backslashes, dollar signs and backticks under PROMPT_SUBST, which a ++ # user can set after activation, and reads % as a prompt escape. The guards keep the substitutions away from ++ # POSIX shells such as dash, which reject them with "Bad substitution" ++ if [ -n "${BASH_VERSION-}" ] && { shopt -q promptvars || shopt -qo posix; }; then ++ PS1='(${VIRTUAL_ENV_PROMPT-}) '"${PS1-}" ++ else ++ _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}" ++ if [ -n "${ZSH_VERSION-}" ]; then ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}" ++ _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}" ++ fi ++ PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" ++ unset _VIRTUAL_PROMPT + fi +- # zsh expands % sequences in PS1 as prompt escapes +- if [ -n "${ZSH_VERSION-}" ]; then +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}" +- 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 4512932..8c84544 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -3,7 +3,7 @@ from __future__ import annotations + import shutil + import subprocess + from argparse import Namespace +-from typing import TYPE_CHECKING ++from typing import TYPE_CHECKING, Final + + import pytest + +@@ -16,6 +16,48 @@ if TYPE_CHECKING: + from pathlib import Path + + ++_BASH: Final = shutil.which("bash") ++ ++ ++@pytest.mark.skipif(_BASH is None or (IS_WIN and "\\git\\" not in _BASH.lower()), reason="requires native 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"), ++ ], ++) ++def test_prompt_requires_known_shell(tmp_path: Path, current_fastest: str, setup: str, expected: str) -> None: ++ session: Final = cli_run([ ++ str(tmp_path / "env"), ++ "--creator", ++ current_fastest, ++ "--without-pip", ++ "--no-periodic-update", ++ "--activators", ++ "bash", ++ "--prompt", ++ "example", ++ ]) ++ result: Final = subprocess.run( ++ [ ++ _BASH, ++ "--norc", ++ "--noprofile", ++ "-c", ++ f'{setup} PS1="base "; . "$1"; printf "%s|%s" "$PS1" "$VIRTUAL_ENV_PROMPT"', ++ "test", ++ str(session.creator.bin_dir / "activate"), ++ ], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ timeout=30, ++ check=True, ++ ) ++ assert result.stdout == f"{expected}|example" ++ ++ + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") + @pytest.mark.parametrize( + ("tcl_lib", "tk_lib", "present"), +@@ -225,9 +267,14 @@ def test_prompt_does_not_run_commands( + pytest.param("e\\", id="trailing-backslash"), + ], + ) ++@pytest.mark.parametrize("compatibility", [pytest.param("42", id="bash42"), pytest.param("51", id="bash51")]) + def test_prompt_renders_name_literally( +- prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str ++ prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], ++ prompt: str, ++ compatibility: str, ++ monkeypatch: pytest.MonkeyPatch, + ) -> None: ++ monkeypatch.setenv("BASH_COMPAT", compatibility) + _, rendered = prompt_after_activate(prompt, "env") + + assert rendered.startswith(f"({prompt}) ") +@@ -249,14 +296,20 @@ def test_bash_activate_sources_under_dash(tmp_path: Path, current_fastest: str) + ]) + + result = subprocess.run( +- ["dash", "-c", '. "$1" && printf "%s" "$VIRTUAL_ENV"', "dash", str(dest / "bin" / "activate")], ++ [ ++ "dash", ++ "-c", ++ 'PS1="base "; . "$1" && printf "%s|%s" "$VIRTUAL_ENV" "$PS1"', ++ "dash", ++ str(dest / "bin" / "activate"), ++ ], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + +- assert (result.returncode, result.stdout) == (0, str(dest)) ++ assert (result.returncode, result.stdout) == (0, f"{dest}|base ") + + + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") +@@ -278,12 +331,48 @@ def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester) -> + def activate_call(self, script): + return super().activate_call(script) + " || exit 1" + +- def print_prompt(self): +- # render PS1 the way bash draws it, since the activator escapes the prompt for that expansion; bash before +- # 4.4 lacks ${PS1@P}, so undo the escapes the way its prompt expansion would ++ def print_prompt(self) -> str: + return ( + 'if ((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404)); then printf "%s\\n" "${PS1@P}"; ' +- "else printf '%s\\n' \"$PS1\" | sed 's/\\\\\\([$`\\\\]\\)/\\1/g'; fi" ++ "else case \"$PS1\" in '(${VIRTUAL_ENV_PROMPT-}) '*) " ++ "printf '(%s) %s\\n' \"$VIRTUAL_ENV_PROMPT\" \"${PS1#'(${VIRTUAL_ENV_PROMPT-}) '}\";; " ++ "*) printf '%s\\n' \"$PS1\";; esac; fi" + ) + + activation_tester(Bash) ++ ++ ++@pytest.mark.skipif(IS_WIN, 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: Path, current_fastest: str, before: str, after: str, expected: str) -> None: ++ session: Final = cli_run([ ++ "--without-pip", ++ str(tmp_path / "env"), ++ "--creator", ++ current_fastest, ++ "--no-periodic-update", ++ "--activators", ++ "bash", ++ "--prompt", ++ "x$(touch PWNED)y", ++ ]) ++ result: Final = subprocess.run( ++ ["bash", "--noprofile", "--norc", "-i"], ++ input=f"{before}\nPS1=__END__\n. {BashActivator.quote(str(session.creator.bin_dir / 'activate'))}\n{after}\nexit\n", ++ cwd=tmp_path, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ check=True, ++ ) ++ assert expected in result.stderr ++ assert "unbound variable" not in result.stderr +-- +2.35.6 diff --git a/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-115586.patch b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-115586.patch new file mode 100644 index 0000000000..b60eec9b35 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-virtualenv/CVE-2026-115586.patch @@ -0,0 +1,526 @@ +From 3598e201f6f044e389136b2347a8a919ac03c5f4 Mon Sep 17 00:00:00 2001 +From: Bernát Gábor +Date: Thu, 22 Oct 2026 13:06:22 -0700 +Subject: [PATCH] fix(bash): escape the prompt for every PS1 state (#3466) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +CVE: CVE-2026-115586 +Upstream-Status: Backport [https://github.com/pypa/virtualenv/commit/3598e201f6f044e389136b2347a8a919ac03c5f4] + +Backport Changes: +- Apply the Bash prompt changes to the top-level activation block and + retain 21.2.1's existing PS1 export; do not add the relocation wrapper. +- Restrict the shared prompt matrix, driver and display helpers to Bash + and Zsh, preserving every upstream payload, state and action for those + shells. Omit the other activator branches, including Xonsh, which is + absent from 21.2.1, and adjust helper documentation to this scope. +- Replace str.removeprefix() with equivalent slicing for Python 3.8. +- Add shlex to the older Bash test module for the upstream child-shell + regression. Omit the relocation-wrapper regression because its + wrapper is absent. +- Carry the deterministic upstream matrix test instead of introducing + its new Hypothesis property suite and dependency infrastructure. +- Omit unrelated redirect/RECORD write changes and their tests, Windows + copy handling, CI/CodeQL/tooling updates, and documentation changes; + the 21.2.1 source archive does not contain the documentation tree. + +Signed-off-by: Hetvi Thakar +--- + src/virtualenv/activation/bash/activate.sh | 45 +++-- + tests/prompt_matrix.py | 194 +++++++++++++++++++++ + tests/unit/activation/test_activator.py | 19 ++ + tests/unit/activation/test_bash.py | 123 ++++++------- + 4 files changed, 305 insertions(+), 76 deletions(-) + create mode 100644 tests/prompt_matrix.py + +diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh +index 992ad94..2c9d29c 100644 +--- a/src/virtualenv/activation/bash/activate.sh ++++ b/src/virtualenv/activation/bash/activate.sh +@@ -49,6 +49,11 @@ deactivate () { + 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 + unset VIRTUAL_ENV_PROMPT + if [ ! "${1-}" = "nondestructive" ] ; then +@@ -116,23 +121,33 @@ fi + if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] && + { [ -n "${BASH_VERSION-}" ] || [ -n "${ZSH_VERSION-}" ]; }; then + _OLD_VIRTUAL_PS1="${PS1-}" +- # bash expands PS1 after decoding its prompt escapes when promptvars is on or in posix mode, so the prompt holds a +- # reference to the variable and its value never reaches that expansion; otherwise only the decode runs and +- # backslashes need doubling. zsh re-expands backslashes, dollar signs and backticks under PROMPT_SUBST, which a +- # user can set after activation, and reads % as a prompt escape. The guards keep the substitutions away from +- # POSIX shells such as dash, which reject them with "Bad substitution" +- if [ -n "${BASH_VERSION-}" ] && { shopt -q promptvars || shopt -qo posix; }; then +- PS1='(${VIRTUAL_ENV_PROMPT-}) '"${PS1-}" ++ if [ -n "${BASH_VERSION-}" ]; then ++ # bash decodes its prompt escapes on every draw and expands the result when promptvars is on or in posix ++ # mode; both can change after activation and an exported PS1 reaches child shells, so the value gets escaped ++ # for both passes whatever the state is now. The escapes are built by concatenation because the replacement ++ # in ${var//pat/rep} loses backslashes depending on the bash version, BASH_COMPAT and patsub_replacement ++ _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 +- _VIRTUAL_PROMPT="${VIRTUAL_ENV_PROMPT//\\/\\\\}" +- if [ -n "${ZSH_VERSION-}" ]; then +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\$/\\\$}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\`/\\\`}" +- _VIRTUAL_PROMPT="${_VIRTUAL_PROMPT//\%/%%}" +- fi +- PS1="(${_VIRTUAL_PROMPT}) ${PS1-}" +- unset _VIRTUAL_PROMPT ++ # zsh inserts a psvar entry into the prompt as data under every prompt option, so the value needs no ++ # escaping, and the %9v reference holds nothing for another shell to expand when PS1 is exported. The guard ++ # above keeps POSIX shells such as dash away from the array assignment ++ _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/prompt_matrix.py b/tests/prompt_matrix.py +new file mode 100644 +index 0000000..7bd0a4c +--- /dev/null ++++ b/tests/prompt_matrix.py +@@ -0,0 +1,194 @@ ++"""Prompt payloads, the shell states that change how a stored prompt is read, and one driver for Bash and Zsh activation.""" ++ ++from __future__ import annotations ++ ++import re ++import shlex ++import shutil ++import subprocess ++from argparse import Namespace ++from typing import TYPE_CHECKING, Final, NamedTuple ++from unittest.mock import create_autospec ++ ++from python_discovery import PythonInfo ++ ++from virtualenv.activation import BashActivator ++from virtualenv.create.creator import Creator ++from virtualenv.info import IS_WIN ++ ++if TYPE_CHECKING: ++ from pathlib import Path ++ ++ ++class Shell(NamedTuple): ++ activator: str ++ script: str ++ program: str ++ states: dict[str, str] ++ actions: dict[str, str] ++ payloads: dict[str, str] ++ ++ ++PROMPT_PAYLOADS: Final = { ++ "command-substitution": "x$(touch PWNED)y", ++ "backticks": "x`touch PWNED`y", ++ "parameter-expansion": "x${$(touch PWNED)}y", ++ "backslash-dollar": "x\\$(touch PWNED)y", ++ "two-backslashes-dollar": "x\\\\$(touch PWNED)y", ++ "three-backslashes-dollar": "x\\\\\\$(touch PWNED)y", ++ "backslash-backtick": "x\\`touch PWNED`y", ++ "quote-breakout": "x'$(touch PWNED)'y", ++ "fish-backslash-quote": "x\\'$(touch PWNED)'\\'", ++ "statement-separator": "a;touch PWNED;b", ++ "placeholder": "A__VIRTUAL_NAME__B$(touch PWNED)", ++ "history": "x!!y", ++ "percent": "x%ny", ++ "trailing-backslash": "e\\", ++} ++ ++SHELLS: Final = { ++ "bash": Shell( ++ "bash", ++ "activate", ++ "bash", ++ { ++ "default": "", ++ "promptvars-off": "shopt -u promptvars", ++ "posix": "set -o posix", ++ "compat42": "BASH_COMPAT=42", ++ "compat42-promptvars-off": "BASH_COMPAT=42; shopt -u promptvars", ++ "patsub-off": "shopt -u patsub_replacement", ++ "compat42-patsub-off": "BASH_COMPAT=42; shopt -u patsub_replacement", ++ "nounset": "set -u", ++ }, ++ { ++ "none": "", ++ "promptvars-on": "shopt -s promptvars", ++ "promptvars-off": "shopt -u promptvars", ++ "posix": "set -o posix", ++ "nounset-unset": "set -u; unset VIRTUAL_ENV_PROMPT", ++ "child-bash": "export PS1; bash --noprofile --norc -i <</dev/null", ++ "child-dash": "export PS1; dash -i <</dev/null", ++ "child-zsh": "export PS1; zsh -f -i <</dev/null", ++ }, ++ PROMPT_PAYLOADS, ++ ), ++ "zsh": Shell( ++ "bash", ++ "activate", ++ "zsh", ++ {"default": "", "prompt-subst": "setopt prompt_subst"}, ++ { ++ "none": "", ++ "prompt-subst-on": "setopt prompt_subst", ++ "prompt-subst-off": "unsetopt prompt_subst", ++ "promptbang": "setopt promptbang", ++ "emulate-sh": "emulate sh", ++ "child-bash": "export PS1; bash --noprofile --norc -i <</dev/null", ++ }, ++ PROMPT_PAYLOADS, ++ ), ++} ++ ++PROMPT_CASES: Final = [ ++ (shell, state, action, payload) ++ for shell, spec in SHELLS.items() ++ for state in spec.states ++ for action in spec.actions ++ for payload in spec.payloads ++] ++ ++ ++def unavailable(shell: str, action: str) -> str | None: ++ """Why the shell, or the child shell the action starts, cannot run here; None when both can.""" ++ if IS_WIN: ++ return f"{shell} activation needs a POSIX system" ++ child: Final = action[len("child-") :] if action.startswith("child-") else None ++ for program in (SHELLS[shell].program, child): ++ if program is not None and shutil.which(program) is None: ++ return f"{program} is not installed" ++ return None ++ ++ ++def generate_script(shell: str, root: Path, prompt: str) -> Path: ++ """Render the shell's activation script in process; the scripts depend on the prompt, not on a real interpreter.""" ++ activator: Final = { ++ "bash": BashActivator, ++ "zsh": BashActivator, ++ }[shell] ++ creator: Final = create_autospec( ++ Creator, ++ instance=True, ++ dest=root, ++ bin_dir=root / "bin", ++ interpreter=create_autospec(PythonInfo, instance=True, tcl_lib=None, tk_lib=None), ++ pyenv_cfg={}, ++ env_name="env", ++ ) ++ creator.bin_dir.mkdir(parents=True) ++ activator(Namespace(prompt=prompt)).generate(creator) ++ return creator.bin_dir / SHELLS[shell].script ++ ++ ++def run_prompt(shell: str, script: Path, state: str, action: str, cwd: Path) -> subprocess.CompletedProcess[str]: ++ """Activate under the state, apply the later action, and draw the prompt the way that shell does.""" ++ spec: Final = SHELLS[shell] ++ before: Final = spec.states[state] ++ after: Final = spec.actions[action] ++ stdin = "" ++ env: dict[str, str] | None = None ++ if shell in {"bash", "zsh"}: ++ # interactive shells draw PS1 before each command they read, so the last draw follows the later action; child ++ # shells an action starts still draw the inherited prompt, into /dev/null, so it cannot mix with the parent's ++ argv = [spec.program, "--noprofile", "--norc", "-i"] if shell == "bash" else [spec.program, "-f", "-i"] ++ stdin = f"{before}\nPS1=__END__\n. {shlex.quote(str(script))}\n{after}\nexit\n" ++ # an empty stdin keeps a child shell from waiting on the test runner's input ++ return subprocess.run( ++ argv, ++ input=stdin, ++ cwd=cwd, ++ env=env, ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ errors="replace", ++ check=False, ++ timeout=120, ++ ) ++ ++ ++def drawn_prompt(shell: str, result: subprocess.CompletedProcess[str]) -> str: ++ """The last prompt an interactive bash or zsh drew, up to the __END__ sentinel.""" ++ output: Final = result.stderr ++ last: Final = output.rsplit("__END__", 1)[0].rsplit("\n", 1)[-1] ++ return last + "__END__" ++ ++ ++def prompt_shown(shell: str, result: subprocess.CompletedProcess[str], prompt: str, state: str, action: str) -> bool: ++ """Whether the shell showed the prompt the way its display rule says.""" ++ pattern: Final = _drawn_pattern(shell, prompt, state, action) ++ return re.fullmatch(rf"\({pattern}\) __END__", drawn_prompt(shell, result)) is not None ++ ++ ++def _drawn_pattern(shell: str, prompt: str, state: str, action: str) -> str: ++ if shell != "bash": ++ # zsh inserts the psvar entry as data under every prompt option ++ return re.escape(prompt) ++ promptvars: Final = {"promptvars-on": True, "promptvars-off": False}.get(action, "promptvars-off" not in state) ++ if "posix" in {state, action}: ++ # posix mode draws !! as ! and a lone ! as the history number ++ return "!".join(r"\d+".join(map(re.escape, piece.split("!"))) for piece in prompt.split("!!")) ++ # without promptvars only the escape-decoding pass runs, so the escapes for the expansion pass stay visible ++ return re.escape(prompt if promptvars else prompt.replace("\\", "\\\\").replace("`", "\\`")) ++ ++ ++__all__ = [ ++ "PROMPT_CASES", ++ "PROMPT_PAYLOADS", ++ "SHELLS", ++ "drawn_prompt", ++ "generate_script", ++ "prompt_shown", ++ "run_prompt", ++ "unavailable", ++] +diff --git a/tests/unit/activation/test_activator.py b/tests/unit/activation/test_activator.py +index 03481c2..8e71600 100644 +--- a/tests/unit/activation/test_activator.py ++++ b/tests/unit/activation/test_activator.py +@@ -1,12 +1,17 @@ + from __future__ import annotations + + from argparse import Namespace ++from typing import TYPE_CHECKING, Final + + import pytest ++from prompt_matrix import PROMPT_CASES, SHELLS, generate_script, prompt_shown, run_prompt, unavailable + + from virtualenv.activation import BashActivator, CShellActivator, FishActivator, PythonActivator + from virtualenv.activation.activator import Activator + ++if TYPE_CHECKING: ++ from pathlib import Path ++ + + @pytest.mark.parametrize("activator_class", [BashActivator, CShellActivator, FishActivator, PythonActivator]) + def test_template_substitution_does_not_expand_replacement_values(monkeypatch, activator_class) -> None: +@@ -35,3 +40,17 @@ def test_activator_prompt_cwd(monkeypatch, tmp_path) -> None: + + activator = FakeActivator(Namespace(prompt=".")) + assert activator.flag_prompt == "magic" ++ ++ ++@pytest.mark.slow ++@pytest.mark.parametrize("case", PROMPT_CASES, ids=["-".join(case) for case in PROMPT_CASES]) ++def test_activator_prompt_stays_data(tmp_path: Path, case: tuple[str, str, str, str]) -> None: ++ shell, state, action, payload = case ++ if reason := unavailable(shell, action): ++ pytest.skip(reason) ++ prompt: Final = SHELLS[shell].payloads[payload] ++ result: Final = run_prompt(shell, generate_script(shell, tmp_path / "env", prompt), state, action, tmp_path) ++ ++ assert ((tmp_path / "PWNED").exists(), prompt_shown(shell, result, prompt, state, action)) == (False, True), ( ++ result.stdout + result.stderr ++ ) +diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py +index 8c84544..8d6d00b 100644 +--- a/tests/unit/activation/test_bash.py ++++ b/tests/unit/activation/test_bash.py +@@ -1,11 +1,13 @@ + from __future__ import annotations + ++import shlex + import shutil + import subprocess + from argparse import Namespace + from typing import TYPE_CHECKING, Final + + import pytest ++from prompt_matrix import generate_script + + from virtualenv.activation import BashActivator + from virtualenv.info import IS_WIN +@@ -23,7 +25,7 @@ _BASH: Final = shutil.which("bash") + @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"), + ], + ) +@@ -237,49 +239,20 @@ def prompt_after_activate( + + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") + @pytest.mark.parametrize( +- ("prompt", "env_name"), ++ "env_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("x\\$(touch PWNED)y", "env", id="prompt-backslash-command-substitution"), +- pytest.param("x\\`touch PWNED`y", "env", id="prompt-backslash-backticks"), +- pytest.param(None, "x$(touch PWNED)y", id="dirname-command-substitution"), ++ pytest.param("x$(touch PWNED)y", id="dirname-command-substitution"), ++ pytest.param("A__VIRTUAL_NAME__B/x'$(touch PWNED)'y", id="placeholder-in-parent-dir"), + ], + ) +-def test_prompt_does_not_run_commands( +- prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], prompt: str | None, env_name: str ++def test_prompt_dirname_does_not_run_commands( ++ prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], env_name: str + ) -> None: +- work_dir, _ = prompt_after_activate(prompt, env_name) ++ work_dir, _ = prompt_after_activate(None, env_name) + + assert not (work_dir / "PWNED").exists() + + +-@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") +-@pytest.mark.parametrize( +- "prompt", +- [ +- pytest.param("myenv", id="plain"), +- pytest.param("a%n$HOME\\b`c`", id="special-characters"), +- pytest.param("a\\$HOME", id="backslash-before-dollar"), +- pytest.param("a\\`c`", id="backslash-before-backtick"), +- pytest.param("x\\\\y", id="doubled-backslash"), +- pytest.param("e\\", id="trailing-backslash"), +- ], +-) +-@pytest.mark.parametrize("compatibility", [pytest.param("42", id="bash42"), pytest.param("51", id="bash51")]) +-def test_prompt_renders_name_literally( +- prompt_after_activate: Callable[[str | None, str], tuple[Path, str]], +- prompt: str, +- compatibility: str, +- monkeypatch: pytest.MonkeyPatch, +-) -> None: +- monkeypatch.setenv("BASH_COMPAT", compatibility) +- _, rendered = prompt_after_activate(prompt, "env") +- +- assert rendered.startswith(f"({prompt}) ") +- +- + @pytest.mark.skipif(IS_WIN or shutil.which("dash") is None, reason="needs dash as a POSIX shell") + def test_bash_activate_sources_under_dash(tmp_path: Path, current_fastest: str) -> None: + dest = tmp_path / "env" +@@ -334,45 +307,73 @@ def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester) -> + def print_prompt(self) -> str: + return ( + 'if ((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404)); then printf "%s\\n" "${PS1@P}"; ' +- "else case \"$PS1\" in '(${VIRTUAL_ENV_PROMPT-}) '*) " +- "printf '(%s) %s\\n' \"$VIRTUAL_ENV_PROMPT\" \"${PS1#'(${VIRTUAL_ENV_PROMPT-}) '}\";; " +- "*) printf '%s\\n' \"$PS1\";; esac; fi" ++ "else printf 'exit\\n' | PS1=\"$PS1\" bash --noprofile --norc -i 2>&1 >/dev/null; fi" + ) + + activation_tester(Bash) + + ++@pytest.mark.skipif(IS_WIN, reason="requires POSIX bash") ++@pytest.mark.parametrize("setup", [pytest.param("", id="default"), pytest.param("BASH_COMPAT=42; ", id="compat42")]) ++def test_bash_prompt_stored_form(tmp_path: Path, setup: str) -> None: ++ result: Final = subprocess.run( ++ [ ++ "bash", ++ "--norc", ++ "--noprofile", ++ "-c", ++ f'{setup}PS1=__END__; . "$1"; printf "%s" "$PS1"', ++ "test", ++ str(generate_script("bash", tmp_path / "env", "a\\b$c`d")), ++ ], ++ capture_output=True, ++ text=True, ++ encoding="utf-8", ++ check=True, ++ ) ++ ++ assert result.stdout == "(a\\\\\\\\b\\$c\\`d) __END__" ++ ++ + @pytest.mark.skipif(IS_WIN, reason="requires POSIX bash") + @pytest.mark.parametrize( +- ("before", "after", "expected"), ++ "child", + [ +- 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"), ++ pytest.param(["bash", "--noprofile", "--norc", "-i"], id="child-bash"), ++ pytest.param(["dash", "-i"], id="child-dash"), + ], + ) +-def test_bash_prompt_options(tmp_path: Path, current_fastest: str, before: str, after: str, expected: str) -> None: +- session: Final = cli_run([ +- "--without-pip", +- str(tmp_path / "env"), +- "--creator", +- current_fastest, +- "--no-periodic-update", +- "--activators", +- "bash", +- "--prompt", +- "x$(touch PWNED)y", +- ]) ++def test_bash_child_shell_draws_inherited_prompt(tmp_path: Path, child: list[str]) -> None: ++ if shutil.which(child[0]) is None: ++ pytest.skip(f"{child[0]} is not installed") ++ subprocess.run( ++ ["bash", "--norc", "--noprofile", "-c", f"export PS1='$(touch CANARY)'; {shlex.join(child)} << None: + result: Final = subprocess.run( +- ["bash", "--noprofile", "--norc", "-i"], +- input=f"{before}\nPS1=__END__\n. {BashActivator.quote(str(session.creator.bin_dir / 'activate'))}\n{after}\nexit\n", ++ [ ++ "zsh", ++ "-f", ++ "-c", ++ 'psvar=(a b); . "$1"; print -r -- "$psvar[9]"; deactivate; print -r -- "${(j:,:)psvar}"', ++ "zsh", ++ str(generate_script("zsh", tmp_path / "env", "plain")), ++ ], + cwd=tmp_path, + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) +- assert expected in result.stderr +- assert "unbound variable" not in result.stderr ++ ++ assert result.stdout == "plain\na,b,,,,,,,\n" +-- +2.35.6 diff --git a/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb b/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb index 9cf1ea3b6b..2c584361ab 100644 --- a/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb +++ b/meta-python/recipes-devtools/python/python3-virtualenv_21.2.1.bb @@ -21,6 +21,8 @@ SRC_URI += " \ file://CVE-2026-113726-dependent.patch \ file://CVE-2026-113726.patch \ file://CVE-2026-113725.patch \ + file://CVE-2026-113723.patch \ + file://CVE-2026-115586.patch \ " BBCLASSEXTEND = "native nativesdk"