mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-11-14 16:07:25 +00:00
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 <hthakar@cisco.com> Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
1 parent
e11dc337c9
commit
8d74b1295e
3 files changed
+761
No files matched your search
@@ -0,0 +1,233 @@
|
||||
From 5f48d711731311428aebfae61d46957762155b22 Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
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 <hthakar@cisco.com>
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,526 @@
|
||||
From 3598e201f6f044e389136b2347a8a919ac03c5f4 Mon Sep 17 00:00:00 2001
|
||||
From: Bernát Gábor <gaborjbernat@gmail.com>
|
||||
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 <hthakar@cisco.com>
|
||||
---
|
||||
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 <<<exit 2>/dev/null",
|
||||
+ "child-dash": "export PS1; dash -i <<<exit 2>/dev/null",
|
||||
+ "child-zsh": "export PS1; zsh -f -i <<<exit 2>/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 <<<exit 2>/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)} <<<exit"],
|
||||
+ cwd=tmp_path,
|
||||
+ capture_output=True,
|
||||
+ text=True,
|
||||
+ encoding="utf-8",
|
||||
+ check=True,
|
||||
+ )
|
||||
+
|
||||
+ assert (tmp_path / "CANARY").exists()
|
||||
+
|
||||
+
|
||||
+@pytest.mark.skipif(IS_WIN or shutil.which("zsh") is None, reason="requires zsh")
|
||||
+def test_zsh_deactivate_restores_psvar(tmp_path: Path) -> 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
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user