diff --git a/docs/changelog/208.security.rst b/docs/changelog/208.security.rst new file mode 100644 index 0000000..138a9ca --- /dev/null +++ b/docs/changelog/208.security.rst @@ -0,0 +1 @@ +Tcl/Tk library detection no longer executes Tk startup profile files from the home or working directory. diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py index a1bdf2d..5bde199 100644 --- a/src/python_discovery/_py_info_collect.py +++ b/src/python_discovery/_py_info_collect.py @@ -184,8 +184,13 @@ def _get_tcl_tk_libs(): # pragma: no cover # tkinter availability varies; teste except ImportError: pass else: + # Tk.__init__ reads profile scripts from HOME or the working directory; probing needs only library metadata. + class _TclWithoutProfile(tk.Tk): + def readprofile(self, *_args, **_kwargs): # ruff:ignore[no-self-use] # Tk override must be an instance method. + return None + try: - tcl = tk.Tcl() + tcl = _TclWithoutProfile(useTk=False) tcl_lib = tcl.eval("info library") tk_lib = PythonInfoCollector._resolve_tk_lib(tcl, tcl_lib) except tk.TclError: diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index 5385cea..d22d488 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from unittest.mock import Mock from pytest_mock import MockerFixture @@ -304,9 +305,16 @@ class _TclError(Exception): """Stands in for tkinter.TclError; an except clause needs a real exception type.""" -def _fake_tkinter(mocker: MockerFixture, eval_side_effect: object) -> None: - module = mocker.MagicMock(TclError=_TclError, **{"Tcl.return_value.eval.side_effect": eval_side_effect}) +def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] | Exception) -> Mock: + evaluate = mocker.Mock(side_effect=eval_side_effect) + profile = mocker.Mock(return_value=None) + interpreter = type( + "Tk", (), {"__init__": lambda self, **_: self.readprofile("Tk", "Tk"), "readprofile": profile, "eval": evaluate} + ) + module = mocker.MagicMock(TclError=_TclError, Tk=interpreter) + module.Tcl.side_effect = lambda: interpreter(useTk=False) mocker.patch.dict(sys.modules, {"tkinter": module}) + return profile def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> None: @@ -326,6 +334,18 @@ def test_tcl_tk_libs_none_on_tcl_error(tmp_path: Path, mocker: MockerFixture) -> assert (info.tcl_lib, info.tk_lib) == (None, None) +def test_tcl_tk_libs_skip_tk_profile(tmp_path: Path, mocker: MockerFixture) -> None: + profile = _fake_tkinter(mocker, {"info library": "/tcl-lib"}.__getitem__) + profile.side_effect = AssertionError("Tk profile loaded") + mocker.patch("python_discovery._py_info_collect.PythonInfoCollector._resolve_tk_lib", return_value=None) + mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) + + info = PythonInfo() + + assert info.tcl_lib == "/tcl-lib" + profile.assert_not_called() + + def test_system_stdlib_empty_when_scheme_lacks_path(mocker: MockerFixture) -> None: names = tuple(name for name in sysconfig.get_path_names() if name not in {"stdlib", "platstdlib"}) mocker.patch.object(sysconfig, "get_path_names", return_value=names)