From f601e56c6f73e100421d8ce94ef6ee79a92cda95 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Sun, 8 Mar 2026 21:18:20 -0400 Subject: [PATCH 01/29] tmpdir: prevent symlink attacks and TOCTOU races (CVE-2025-71176) Open the base temporary directory using `os.open` with `O_NOFOLLOW` and `O_DIRECTORY` flags to prevent symlink attacks. Use the resulting file descriptor for `fstat` and `fchmod` operations to eliminate Time-of-Check Time-of-Use (TOCTOU) races. Co-authored-by: Windsurf, Gemini --- src/_pytest/tmpdir.py | 31 ++++++++++++++++++++++++------- testing/test_tmpdir.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 855ad273ecf..121e37e80fb 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -172,14 +172,31 @@ def getbasetemp(self) -> Path: # just error out on this, at least for a while. uid = get_user_id() if uid is not None: - rootdir_stat = rootdir.stat() - if rootdir_stat.st_uid != uid: + # Open the directory without following symlinks to prevent + # symlink attacks (CVE-2025-71176). Using a file descriptor + # for fstat/fchmod also eliminates TOCTOU races. + open_flags = os.O_RDONLY + for _flag in ("O_NOFOLLOW", "O_DIRECTORY"): + open_flags |= getattr(os, _flag, 0) + try: + dir_fd = os.open(str(rootdir), open_flags) + except OSError as e: raise OSError( - f"The temporary directory {rootdir} is not owned by the current user. " - "Fix this and try again." - ) - if (rootdir_stat.st_mode & 0o077) != 0: - os.chmod(rootdir, rootdir_stat.st_mode & ~0o077) + f"The temporary directory {rootdir} could not be " + "safely opened (it may be a symlink). " + "Remove the symlink or directory and try again." + ) from e + try: + rootdir_stat = os.fstat(dir_fd) + if rootdir_stat.st_uid != uid: + raise OSError( + f"The temporary directory {rootdir} is not owned by the current user. " + "Fix this and try again." + ) + if (rootdir_stat.st_mode & 0o077) != 0: + os.fchmod(dir_fd, rootdir_stat.st_mode & ~0o077) + finally: + os.close(dir_fd) keep = self._retention_count if self._retention_policy == "none": keep = 0 diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 12891d81488..8180eb3802f 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -619,3 +619,31 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions( # After - fixed. assert (basetemp.parent.stat().st_mode & 0o077) == 0 + + +@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions") +def test_tmp_path_factory_rejects_symlink_rootdir( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """CVE-2025-71176: verify that a symlink at the pytest-of- location + is rejected to prevent symlink attacks.""" + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) + + # Pre-create a target directory that the symlink will point to. + attacker_dir = tmp_path / "attacker-controlled" + attacker_dir.mkdir(mode=0o700) + + # Figure out what rootdir name pytest would use, then replace it + # with a symlink pointing to the attacker-controlled directory. + import getpass + + user = getpass.getuser() + rootdir = tmp_path / f"pytest-of-{user}" + # Remove the real dir if a prior factory call created it. + if rootdir.exists(): + rootdir.rmdir() + rootdir.symlink_to(attacker_dir) + + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + with pytest.raises(OSError, match="could not be safely opened"): + tmp_factory.getbasetemp() From ec18caa961a2636a38b2b5349b1e006559fa62f9 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 00:42:32 -0400 Subject: [PATCH 02/29] docs: added a bugfix changelog entry Co-authored-by: Windsurf, Gemini --- changelog/13669.bugfix.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/13669.bugfix.rst diff --git a/changelog/13669.bugfix.rst b/changelog/13669.bugfix.rst new file mode 100644 index 00000000000..81f443bf7dc --- /dev/null +++ b/changelog/13669.bugfix.rst @@ -0,0 +1,3 @@ +Fixed a symlink attack vulnerability (CVE-2025-71176) in the :fixture:`tmp_path` fixture's base directory handling. + +The ``pytest-of-`` directory under the system temp root is now opened with ``O_NOFOLLOW`` and verified using file-descriptor-based ``fstat``/``fchmod``, preventing symlink attacks and TOCTOU races. From b3cb812e159e825a2f8f7df77706ce31b9d8cc0c Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 00:55:27 -0400 Subject: [PATCH 03/29] chore: added name to `AUTHORS` file --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 6885ec6e793..8329ddf9288 100644 --- a/AUTHORS +++ b/AUTHORS @@ -264,6 +264,7 @@ Kojo Idrissa Kostis Anagnostopoulos Kristoffer Nordström Kyle Altendorf +Laura Kaminskiy Lawrence Mitchell Lee Kamentsky Leonardus Chen From 5894e2585d351df92238468f3e9e15ad9485a1dc Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 01:04:14 -0400 Subject: [PATCH 04/29] chore: adding test coverage Co-authored-by Windsurf --- testing/test_tmpdir.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 8180eb3802f..b08eec4e8b3 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -647,3 +647,37 @@ def test_tmp_path_factory_rejects_symlink_rootdir( tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) with pytest.raises(OSError, match="could not be safely opened"): tmp_factory.getbasetemp() + + +@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions") +def test_tmp_path_factory_rejects_wrong_owner( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """CVE-2025-71176: verify that a rootdir owned by a different user is + rejected (covers the fstat uid mismatch branch).""" + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) + + # Make get_user_id() return a uid that won't match the directory owner. + monkeypatch.setattr("_pytest.tmpdir.get_user_id", lambda: os.getuid() + 1) + + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + with pytest.raises(OSError, match="not owned by the current user"): + tmp_factory.getbasetemp() + + +@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions") +def test_tmp_path_factory_nofollow_flag_missing( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """CVE-2025-71176: verify that the code still works when O_NOFOLLOW or + O_DIRECTORY flags are not available on the platform.""" + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) + monkeypatch.delattr(os, "O_NOFOLLOW", raising=False) + monkeypatch.delattr(os, "O_DIRECTORY", raising=False) + + tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + basetemp = tmp_factory.getbasetemp() + + # Should still create the directory with safe permissions. + assert basetemp.is_dir() + assert (basetemp.parent.stat().st_mode & 0o077) == 0 From 7f93f0aaad79e38aed81a0a9fb8e5aa079dcf730 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 01:18:19 -0400 Subject: [PATCH 05/29] chore: Add tests for tmp_path retention configuration validation Co-authored-by Windsurf, Gemini --- testing/test_tmpdir.py | 94 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index b08eec4e8b3..a0a5a1b642b 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -681,3 +681,97 @@ def test_tmp_path_factory_nofollow_flag_missing( # Should still create the directory with safe permissions. assert basetemp.is_dir() assert (basetemp.parent.stat().st_mode & 0o077) == 0 + + +def test_tmp_path_factory_from_config_rejects_negative_count( + tmp_path: Path, +) -> None: + """Verify that a negative tmp_path_retention_count raises ValueError.""" + + @dataclasses.dataclass + class BadCountConfig: + basetemp: str | Path = "" + + @property + def trace(self): + return self + + def get(self, key): + return lambda *k: None + + def getini(self, name): + if name == "tmp_path_retention_count": + return -1 + elif name == "tmp_path_retention_policy": + return "all" + else: + assert False + + @property + def option(self): + return self + + config = cast(Config, BadCountConfig(tmp_path)) + with pytest.raises(ValueError, match="tmp_path_retention_count must be >= 0"): + TempPathFactory.from_config(config, _ispytest=True) + + +def test_tmp_path_factory_from_config_rejects_invalid_policy( + tmp_path: Path, +) -> None: + """Verify that an invalid tmp_path_retention_policy raises ValueError.""" + + @dataclasses.dataclass + class BadPolicyConfig: + basetemp: str | Path = "" + + @property + def trace(self): + return self + + def get(self, key): + return lambda *k: None + + def getini(self, name): + if name == "tmp_path_retention_count": + return 3 + elif name == "tmp_path_retention_policy": + return "invalid_policy" + else: + assert False + + @property + def option(self): + return self + + config = cast(Config, BadPolicyConfig(tmp_path)) + with pytest.raises(ValueError, match="tmp_path_retention_policy must be either"): + TempPathFactory.from_config(config, _ispytest=True) + + +def test_tmp_path_factory_none_policy_sets_keep_zero( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """Verify that retention_policy='none' sets keep=0.""" + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) + tmp_factory = TempPathFactory( + None, 3, "none", lambda *args: None, _ispytest=True + ) + basetemp = tmp_factory.getbasetemp() + assert basetemp.is_dir() + + +def test_pytest_sessionfinish_noop_when_no_basetemp( + pytester: Pytester, +) -> None: + """Verify that pytest_sessionfinish returns early when basetemp is None.""" + from _pytest.tmpdir import pytest_sessionfinish + + p = pytester.makepyfile( + """ + def test_no_tmp(): + pass + """ + ) + result = pytester.runpytest(p) + result.assert_outcomes(passed=1) From e232f12a5511a633a0c086cf444bf4e3859a96a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:22:05 +0000 Subject: [PATCH 06/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a0a5a1b642b..1adc53b5d16 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -754,9 +754,7 @@ def test_tmp_path_factory_none_policy_sets_keep_zero( ) -> None: """Verify that retention_policy='none' sets keep=0.""" monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - tmp_factory = TempPathFactory( - None, 3, "none", lambda *args: None, _ispytest=True - ) + tmp_factory = TempPathFactory(None, 3, "none", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() assert basetemp.is_dir() @@ -765,8 +763,6 @@ def test_pytest_sessionfinish_noop_when_no_basetemp( pytester: Pytester, ) -> None: """Verify that pytest_sessionfinish returns early when basetemp is None.""" - from _pytest.tmpdir import pytest_sessionfinish - p = pytester.makepyfile( """ def test_no_tmp(): From fe0832ba4fd53c19e037882f131c776291dcb180 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 01:40:53 -0400 Subject: [PATCH 07/29] chore: improve coide coverage for edge case Co-authored-by: Windsurf --- testing/test_tmpdir.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a0a5a1b642b..56a5711e8b0 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -775,3 +775,25 @@ def test_no_tmp(): ) result = pytester.runpytest(p) result.assert_outcomes(passed=1) + + +def test_pytest_sessionfinish_handles_missing_basetemp_dir( + tmp_path: Path, +) -> None: + """Cover the branch where basetemp is set but the directory no longer + exists when pytest_sessionfinish runs (314->320 partial branch).""" + from _pytest.tmpdir import pytest_sessionfinish + + factory = TempPathFactory( + None, 3, "failed", lambda *args: None, _ispytest=True + ) + # Point _basetemp at a path that does not exist on disk. + factory._basetemp = tmp_path / "already-gone" + + class FakeSession: + class config: + _tmp_path_factory = factory + + # exitstatus=0 + policy="failed" + _given_basetemp=None enters the + # cleanup block; basetemp.is_dir() is False so rmtree is skipped. + pytest_sessionfinish(FakeSession, exitstatus=0) From 068fd4e220caa2b0caff492f0ffadb0824d64cf6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:42:08 +0000 Subject: [PATCH 08/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index a9d843c6e7b..1f1ed3d28f1 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -780,9 +780,7 @@ def test_pytest_sessionfinish_handles_missing_basetemp_dir( exists when pytest_sessionfinish runs (314->320 partial branch).""" from _pytest.tmpdir import pytest_sessionfinish - factory = TempPathFactory( - None, 3, "failed", lambda *args: None, _ispytest=True - ) + factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True) # Point _basetemp at a path that does not exist on disk. factory._basetemp = tmp_path / "already-gone" From a72493973ea2759b90ea02b1e1dda931bf930138 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy Date: Mon, 9 Mar 2026 06:59:37 -0400 Subject: [PATCH 09/29] chore: remove dead code Co-authored-by: Windsurf --- testing/test_tmpdir.py | 36 +++++------------------------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 1f1ed3d28f1..c994b8a916f 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -639,9 +639,9 @@ def test_tmp_path_factory_rejects_symlink_rootdir( user = getpass.getuser() rootdir = tmp_path / f"pytest-of-{user}" - # Remove the real dir if a prior factory call created it. - if rootdir.exists(): - rootdir.rmdir() + # Ensure the real dir exists so the cleanup branch is exercised. + rootdir.mkdir(mode=0o700, exist_ok=True) + rootdir.rmdir() rootdir.symlink_to(attacker_dir) tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) @@ -692,24 +692,10 @@ def test_tmp_path_factory_from_config_rejects_negative_count( class BadCountConfig: basetemp: str | Path = "" - @property - def trace(self): - return self - - def get(self, key): - return lambda *k: None - def getini(self, name): if name == "tmp_path_retention_count": return -1 - elif name == "tmp_path_retention_policy": - return "all" - else: - assert False - - @property - def option(self): - return self + assert False config = cast(Config, BadCountConfig(tmp_path)) with pytest.raises(ValueError, match="tmp_path_retention_count must be >= 0"): @@ -725,24 +711,12 @@ def test_tmp_path_factory_from_config_rejects_invalid_policy( class BadPolicyConfig: basetemp: str | Path = "" - @property - def trace(self): - return self - - def get(self, key): - return lambda *k: None - def getini(self, name): if name == "tmp_path_retention_count": return 3 elif name == "tmp_path_retention_policy": return "invalid_policy" - else: - assert False - - @property - def option(self): - return self + assert False config = cast(Config, BadPolicyConfig(tmp_path)) with pytest.raises(ValueError, match="tmp_path_retention_policy must be either"): From ed4a728355aefafadcc3359af4cc9c63177ae8d7 Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy <23003243+laurac8r@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:47:19 -0400 Subject: [PATCH 10/29] Apply suggestion from @webknjaz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) --- changelog/13669.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/13669.bugfix.rst b/changelog/13669.bugfix.rst index 81f443bf7dc..37acf34f143 100644 --- a/changelog/13669.bugfix.rst +++ b/changelog/13669.bugfix.rst @@ -1,3 +1,3 @@ -Fixed a symlink attack vulnerability (CVE-2025-71176) in the :fixture:`tmp_path` fixture's base directory handling. +Fixed a symlink attack vulnerability (:cve:`2025-71176`) in the :fixture:`tmp_path` fixture's base directory handling -- by :user:`laurac8r`. The ``pytest-of-`` directory under the system temp root is now opened with ``O_NOFOLLOW`` and verified using file-descriptor-based ``fstat``/``fchmod``, preventing symlink attacks and TOCTOU races. From 206731a4cdc02263f4743a655aac55dded1e48ba Mon Sep 17 00:00:00 2001 From: Laura Kaminskiy <23003243+laurac8r@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:50:02 -0400 Subject: [PATCH 11/29] Apply suggestion from @webknjaz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) --- testing/test_tmpdir.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index c994b8a916f..6fa2fbcb37c 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -621,7 +621,7 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions( assert (basetemp.parent.stat().st_mode & 0o077) == 0 -@pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions") +@skip_if_no_getuid def test_tmp_path_factory_rejects_symlink_rootdir( tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: From d456ad41c4032dae7644dc7c6525be16b26c7625 Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 13:46:49 -0400 Subject: [PATCH 12/29] docs: enhance CVE-2025-71176 changelog entry with hyperlinks Co-authored-by: Windsurf --- changelog/13669.bugfix.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/changelog/13669.bugfix.rst b/changelog/13669.bugfix.rst index 81f443bf7dc..f216b0b3ae2 100644 --- a/changelog/13669.bugfix.rst +++ b/changelog/13669.bugfix.rst @@ -1,3 +1,9 @@ -Fixed a symlink attack vulnerability (CVE-2025-71176) in the :fixture:`tmp_path` fixture's base directory handling. +Fixed a symlink attack vulnerability ([CVE-2025-71176](https://github.com/pytest-dev/pytest/issues/13669)) in +the [tmp_path](https://github.com/pytest-dev/pytest/blob/295d9da900a0dbe8b4093d6a6bc977cd567aa4b0/src/_pytest/tmpdir.py#L258) +fixture's base directory handling. -The ``pytest-of-`` directory under the system temp root is now opened with ``O_NOFOLLOW`` and verified using file-descriptor-based ``fstat``/``fchmod``, preventing symlink attacks and TOCTOU races. +The ``pytest-of-`` directory under the system temp root is now opened +with [O_NOFOLLOW](https://man7.org/linux/man-pages/man2/open.2.html#:~:text=not%20have%20one.-,O_NOFOLLOW,-If%20the%20trailing) +and verified using +file-descriptor-based [fstat](https://linux.die.net/man/2/fstat)/[fchmod](https://linux.die.net/man/2/fchmod), +preventing symlink attacks and [TOCTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use) races. From c02568144198de04ac40253a7b5677a7d4ce0f96 Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 14:16:06 -0400 Subject: [PATCH 13/29] refactor(tmpdir): extract _safe_open_dir into a reusable context manager Co-authored-by: Windsurf --- src/_pytest/tmpdir.py | 53 ++++++++++++++++++++++++------------ testing/test_tmpdir.py | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 121e37e80fb..bc580678420 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator +import contextlib import dataclasses import os from pathlib import Path @@ -37,6 +38,40 @@ RetentionType = Literal["all", "failed", "none"] +@contextlib.contextmanager +def _safe_open_dir(path: Path) -> Generator[int]: + """Open a directory without following symlinks and yield its file descriptor. + + Uses O_NOFOLLOW and O_DIRECTORY (when available) to prevent symlink + attacks (CVE-2025-71176). The fd-based operations (fstat, fchmod) + also eliminate TOCTOU races. + + Args: + path: Directory to open. + + Yields: + An open file descriptor for the directory. + + Raises: + OSError: If the path cannot be safely opened (e.g. it is a symlink). + """ + open_flags = os.O_RDONLY + for _flag in ("O_NOFOLLOW", "O_DIRECTORY"): + open_flags |= getattr(os, _flag, 0) + try: + dir_fd = os.open(str(path), open_flags) + except OSError as e: + raise OSError( + f"The temporary directory {path} could not be " + "safely opened (it may be a symlink). " + "Remove the symlink or directory and try again." + ) from e + try: + yield dir_fd + finally: + os.close(dir_fd) + + @final @dataclasses.dataclass class TempPathFactory: @@ -172,21 +207,7 @@ def getbasetemp(self) -> Path: # just error out on this, at least for a while. uid = get_user_id() if uid is not None: - # Open the directory without following symlinks to prevent - # symlink attacks (CVE-2025-71176). Using a file descriptor - # for fstat/fchmod also eliminates TOCTOU races. - open_flags = os.O_RDONLY - for _flag in ("O_NOFOLLOW", "O_DIRECTORY"): - open_flags |= getattr(os, _flag, 0) - try: - dir_fd = os.open(str(rootdir), open_flags) - except OSError as e: - raise OSError( - f"The temporary directory {rootdir} could not be " - "safely opened (it may be a symlink). " - "Remove the symlink or directory and try again." - ) from e - try: + with _safe_open_dir(rootdir) as dir_fd: rootdir_stat = os.fstat(dir_fd) if rootdir_stat.st_uid != uid: raise OSError( @@ -195,8 +216,6 @@ def getbasetemp(self) -> Path: ) if (rootdir_stat.st_mode & 0o077) != 0: os.fchmod(dir_fd, rootdir_stat.st_mode & ~0o077) - finally: - os.close(dir_fd) keep = self._retention_count if self._retention_policy == "none": keep = 0 diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 6fa2fbcb37c..5eb49a4b5d8 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -21,10 +21,15 @@ from _pytest.pathlib import register_cleanup_lock_removal from _pytest.pathlib import rm_rf from _pytest.pytester import Pytester +from _pytest.tmpdir import _safe_open_dir from _pytest.tmpdir import get_user from _pytest.tmpdir import TempPathFactory import pytest +skip_if_no_getuid = pytest.mark.skipif( + not hasattr(os, "getuid"), reason="checks unix permissions" +) + def test_tmp_path_fixture(pytester: Pytester) -> None: p = pytester.copy_example("tmpdir/tmp_path_fixture.py") @@ -765,3 +770,59 @@ class config: # exitstatus=0 + policy="failed" + _given_basetemp=None enters the # cleanup block; basetemp.is_dir() is False so rmtree is skipped. pytest_sessionfinish(FakeSession, exitstatus=0) + + +# -- Direct unit tests for _safe_open_dir context manager -- + + +class TestSafeOpenDir: + """Unit tests for the _safe_open_dir context manager (CVE-2025-71176).""" + + @skip_if_no_getuid + def test_yields_valid_fd_for_real_directory(self, tmp_path: Path) -> None: + """Happy path: yields a valid file descriptor for a real directory.""" + with _safe_open_dir(tmp_path) as fd: + st = os.fstat(fd) + assert stat.S_ISDIR(st.st_mode) + + @skip_if_no_getuid + def test_fd_is_closed_after_context_exit(self, tmp_path: Path) -> None: + """The file descriptor must be closed when the context exits.""" + with _safe_open_dir(tmp_path) as fd: + pass + # After exiting, fstat on the closed fd should raise. + with pytest.raises(OSError): + os.fstat(fd) + + @skip_if_no_getuid + def test_rejects_symlink(self, tmp_path: Path) -> None: + """A symlink must be rejected with a clear error message.""" + real_dir = tmp_path / "real" + real_dir.mkdir() + link = tmp_path / "link" + link.symlink_to(real_dir) + + with pytest.raises(OSError, match="could not be safely opened"): + with _safe_open_dir(link): + pass # pragma: no cover + + @skip_if_no_getuid + def test_rejects_nonexistent_path(self, tmp_path: Path) -> None: + """A non-existent path must be rejected with a clear error message.""" + missing = tmp_path / "does-not-exist" + with pytest.raises(OSError, match="could not be safely opened"): + with _safe_open_dir(missing): + pass # pragma: no cover + + @skip_if_no_getuid + def test_fd_closed_on_exception_inside_context(self, tmp_path: Path) -> None: + """The fd must be closed even if the caller raises inside the with block.""" + fd_holder: list[int] = [] + with pytest.raises(RuntimeError, match="boom"): + with _safe_open_dir(tmp_path) as fd: + fd_holder.append(fd) + raise RuntimeError("boom") + + # fd should be closed despite the exception. + with pytest.raises(OSError): + os.fstat(fd_holder[0]) From f2c6f23b7931de6d43291f3bc85a8ce21ecb63dd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:16:32 +0000 Subject: [PATCH 14/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 5eb49a4b5d8..3952f9f1b47 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -26,6 +26,7 @@ from _pytest.tmpdir import TempPathFactory import pytest + skip_if_no_getuid = pytest.mark.skipif( not hasattr(os, "getuid"), reason="checks unix permissions" ) From d58ba2afa21c4c36439c5c8f8269e12268304234 Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 14:17:06 -0400 Subject: [PATCH 15/29] docs: update docstring --- testing/test_tmpdir.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 5eb49a4b5d8..259b133aabf 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -756,7 +756,7 @@ def test_pytest_sessionfinish_handles_missing_basetemp_dir( tmp_path: Path, ) -> None: """Cover the branch where basetemp is set but the directory no longer - exists when pytest_sessionfinish runs (314->320 partial branch).""" + exists when pytest_sessionfinish runs.""" from _pytest.tmpdir import pytest_sessionfinish factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True) From 45bdce95cbe41a7003bd7cc88a7f895789dd045c Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 14:24:56 -0400 Subject: [PATCH 16/29] refactor(testing): consolidate imports in test_tmpdir.py Co-authored-by: Windsurf --- testing/test_tmpdir.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 4de01acd894..b4cde167f6b 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -13,20 +13,24 @@ from _pytest import pathlib from _pytest.config import Config from _pytest.monkeypatch import MonkeyPatch -from _pytest.pathlib import cleanup_numbered_dir -from _pytest.pathlib import create_cleanup_lock -from _pytest.pathlib import make_numbered_dir -from _pytest.pathlib import maybe_delete_a_numbered_dir -from _pytest.pathlib import on_rm_rf_error -from _pytest.pathlib import register_cleanup_lock_removal -from _pytest.pathlib import rm_rf +from _pytest.pathlib import ( + cleanup_numbered_dir, + create_cleanup_lock, + make_numbered_dir, + maybe_delete_a_numbered_dir, + on_rm_rf_error, + register_cleanup_lock_removal, + rm_rf, +) from _pytest.pytester import Pytester -from _pytest.tmpdir import _safe_open_dir -from _pytest.tmpdir import get_user -from _pytest.tmpdir import TempPathFactory +from _pytest.tmpdir import ( + _safe_open_dir, + get_user, + TempPathFactory, + pytest_sessionfinish, +) import pytest - skip_if_no_getuid = pytest.mark.skipif( not hasattr(os, "getuid"), reason="checks unix permissions" ) @@ -758,7 +762,6 @@ def test_pytest_sessionfinish_handles_missing_basetemp_dir( ) -> None: """Cover the branch where basetemp is set but the directory no longer exists when pytest_sessionfinish runs.""" - from _pytest.tmpdir import pytest_sessionfinish factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True) # Point _basetemp at a path that does not exist on disk. From 879767b48abe6227e549c9d1d23a37bccd56a73f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:25:19 +0000 Subject: [PATCH 17/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index b4cde167f6b..25620ca974a 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -13,24 +13,21 @@ from _pytest import pathlib from _pytest.config import Config from _pytest.monkeypatch import MonkeyPatch -from _pytest.pathlib import ( - cleanup_numbered_dir, - create_cleanup_lock, - make_numbered_dir, - maybe_delete_a_numbered_dir, - on_rm_rf_error, - register_cleanup_lock_removal, - rm_rf, -) +from _pytest.pathlib import cleanup_numbered_dir +from _pytest.pathlib import create_cleanup_lock +from _pytest.pathlib import make_numbered_dir +from _pytest.pathlib import maybe_delete_a_numbered_dir +from _pytest.pathlib import on_rm_rf_error +from _pytest.pathlib import register_cleanup_lock_removal +from _pytest.pathlib import rm_rf from _pytest.pytester import Pytester -from _pytest.tmpdir import ( - _safe_open_dir, - get_user, - TempPathFactory, - pytest_sessionfinish, -) +from _pytest.tmpdir import _safe_open_dir +from _pytest.tmpdir import get_user +from _pytest.tmpdir import pytest_sessionfinish +from _pytest.tmpdir import TempPathFactory import pytest + skip_if_no_getuid = pytest.mark.skipif( not hasattr(os, "getuid"), reason="checks unix permissions" ) @@ -762,7 +759,6 @@ def test_pytest_sessionfinish_handles_missing_basetemp_dir( ) -> None: """Cover the branch where basetemp is set but the directory no longer exists when pytest_sessionfinish runs.""" - factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True) # Point _basetemp at a path that does not exist on disk. factory._basetemp = tmp_path / "already-gone" From ab3d9e473f4712b3b6da12512c84d86495e974ac Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 14:28:33 -0400 Subject: [PATCH 18/29] refactor: consolidate imports and hoist getpass to module level Co-authored-by: Windsurf --- testing/test_tmpdir.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index b4cde167f6b..2ba4599f862 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -31,6 +31,8 @@ ) import pytest +import getpass + skip_if_no_getuid = pytest.mark.skipif( not hasattr(os, "getuid"), reason="checks unix permissions" ) @@ -645,10 +647,9 @@ def test_tmp_path_factory_rejects_symlink_rootdir( # Figure out what rootdir name pytest would use, then replace it # with a symlink pointing to the attacker-controlled directory. - import getpass - user = getpass.getuser() rootdir = tmp_path / f"pytest-of-{user}" + # Ensure the real dir exists so the cleanup branch is exercised. rootdir.mkdir(mode=0o700, exist_ok=True) rootdir.rmdir() From 64aa0f1b4043d5b599f2830d38b4a90ac15b9d5e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:33:22 +0000 Subject: [PATCH 19/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 4562418f9f9..949357f672c 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -3,6 +3,7 @@ from collections.abc import Callable import dataclasses +import getpass import os from pathlib import Path import stat @@ -27,7 +28,6 @@ from _pytest.tmpdir import TempPathFactory import pytest -import getpass skip_if_no_getuid = pytest.mark.skipif( not hasattr(os, "getuid"), reason="checks unix permissions" From e1ab0603fe13976a15eda0e647bbd387efbc6498 Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 14:39:11 -0400 Subject: [PATCH 20/29] test(tmpdir): make test_pytest_sessionfinish_handles_missing_basetemp_dir isolated Co-authored-by: Windsurf --- testing/test_tmpdir.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 4562418f9f9..09f0efd9f27 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -754,14 +754,12 @@ def test_no_tmp(): result.assert_outcomes(passed=1) -def test_pytest_sessionfinish_handles_missing_basetemp_dir( - tmp_path: Path, -) -> None: +def test_pytest_sessionfinish_handles_missing_basetemp_dir() -> None: """Cover the branch where basetemp is set but the directory no longer exists when pytest_sessionfinish runs.""" factory = TempPathFactory(None, 3, "failed", lambda *args: None, _ispytest=True) # Point _basetemp at a path that does not exist on disk. - factory._basetemp = tmp_path / "already-gone" + factory._basetemp = Path("/nonexistent/already-gone") class FakeSession: class config: From 3a2786503ba6e7758cab473c116c6e244c789ed6 Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 14:49:12 -0400 Subject: [PATCH 21/29] hotfix: mitigate DoS when a non-directory file blocks pytest-of- Co-authored-by: Windsurf --- src/_pytest/tmpdir.py | 53 ++++++++++++++++++++++++++---- testing/test_tmpdir.py | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index bc580678420..30b4f33324c 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -72,6 +72,35 @@ def _safe_open_dir(path: Path) -> Generator[int]: os.close(dir_fd) +def _try_ensure_directory(path: Path) -> Path | None: + """Try to create *path* as a directory (mode 0o700). + + If a non-directory file is blocking the path (e.g. placed by another user), + attempt to remove it first and retry. Returns the path on success, or + ``None`` when the directory cannot be created. + """ + try: + path.mkdir(mode=0o700, exist_ok=True) + return path + except OSError: + pass + + # A non-directory entry (regular file, socket, …) may be squatting on the + # name. Try to remove it so we can create our directory. + if path.exists() and not path.is_dir(): + try: + path.unlink() + except OSError: + return None + try: + path.mkdir(mode=0o700) + return path + except OSError: + return None + + return None + + @final @dataclasses.dataclass class TempPathFactory: @@ -192,13 +221,23 @@ def getbasetemp(self) -> Path: user = get_user() or "unknown" # use a sub-directory in the temproot to speed-up # make_numbered_dir() call - rootdir = temproot.joinpath(f"pytest-of-{user}") - try: - rootdir.mkdir(mode=0o700, exist_ok=True) - except OSError: - # getuser() likely returned illegal characters for the platform, use unknown back off mechanism - rootdir = temproot.joinpath("pytest-of-unknown") - rootdir.mkdir(mode=0o700, exist_ok=True) + rootdir = _try_ensure_directory( + temproot.joinpath(f"pytest-of-{user}") + ) + if rootdir is None: + # getuser() likely returned illegal characters for the + # platform, use unknown back off mechanism + rootdir = _try_ensure_directory( + temproot.joinpath("pytest-of-unknown") + ) + if rootdir is None: + # All predictable names are blocked (e.g. by a non-directory + # file we cannot remove). Fall back to a unique directory + # so that pytest can still function. + rootdir = Path( + tempfile.mkdtemp(prefix=f"pytest-of-{user}-", dir=temproot) + ) + os.chmod(rootdir, 0o700) # Because we use exist_ok=True with a predictable name, make sure # we are the owners, to prevent any funny business (on unix, where # temproot is usually shared). diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index bf3c8ed2462..bc27ba9747e 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -23,6 +23,7 @@ from _pytest.pathlib import rm_rf from _pytest.pytester import Pytester from _pytest.tmpdir import _safe_open_dir +from _pytest.tmpdir import _try_ensure_directory from _pytest.tmpdir import get_user from _pytest.tmpdir import pytest_sessionfinish from _pytest.tmpdir import TempPathFactory @@ -770,6 +771,79 @@ class config: pytest_sessionfinish(FakeSession, exitstatus=0) +# -- Unit tests for _try_ensure_directory -- + + +class TestTryEnsureDirectory: + """Tests for _try_ensure_directory which mitigates the DoS where an + attacker pre-creates regular files at /tmp/pytest-of-.""" + + def test_creates_new_directory(self, tmp_path: Path) -> None: + target = tmp_path / "newdir" + result = _try_ensure_directory(target) + assert result == target + assert target.is_dir() + + def test_returns_existing_directory(self, tmp_path: Path) -> None: + target = tmp_path / "existingdir" + target.mkdir(mode=0o700) + result = _try_ensure_directory(target) + assert result == target + assert target.is_dir() + + def test_removes_blocking_file_and_creates_dir(self, tmp_path: Path) -> None: + """If a regular file squats on the name we can remove, mkdir succeeds.""" + target = tmp_path / "blocked" + target.touch() + result = _try_ensure_directory(target) + assert result == target + assert target.is_dir() + + def test_returns_none_when_unlink_fails( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """When we cannot remove the blocking file, return None.""" + target = tmp_path / "blocked" + target.touch() + monkeypatch.setattr(Path, "unlink", _raise_oserror) + result = _try_ensure_directory(target) + assert result is None + + def test_returns_none_for_unresolvable_path(self) -> None: + result = _try_ensure_directory(Path("/no/such/parent/dir")) + assert result is None + + +def _raise_oserror(*args: object, **kwargs: object) -> None: + raise OSError("simulated permission denied") + + +def test_getbasetemp_falls_back_to_mkdtemp_when_paths_blocked( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """When both pytest-of- and pytest-of-unknown are blocked by + non-directory files that cannot be removed, getbasetemp falls back to + tempfile.mkdtemp so pytest can still function (DoS mitigation).""" + temproot = tmp_path / "temproot" + temproot.mkdir() + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(temproot)) + + user = get_user() or "unknown" + # Create blocking files for both predictable paths. + (temproot / f"pytest-of-{user}").touch() + if user != "unknown": + (temproot / "pytest-of-unknown").touch() + + # Make the blocking files undeletable by monkeypatching Path.unlink. + monkeypatch.setattr(Path, "unlink", _raise_oserror) + + factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + basetemp = factory.getbasetemp() + assert basetemp.is_dir() + # The rootdir should start with the mkdtemp prefix. + assert basetemp.parent.name.startswith(f"pytest-of-{user}-") + + # -- Direct unit tests for _safe_open_dir context manager -- From e403fbf53056e9f7323f97d497d3a98af5a82fe4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:49:59 +0000 Subject: [PATCH 22/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/tmpdir.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 30b4f33324c..4b4526e541a 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -221,15 +221,11 @@ def getbasetemp(self) -> Path: user = get_user() or "unknown" # use a sub-directory in the temproot to speed-up # make_numbered_dir() call - rootdir = _try_ensure_directory( - temproot.joinpath(f"pytest-of-{user}") - ) + rootdir = _try_ensure_directory(temproot.joinpath(f"pytest-of-{user}")) if rootdir is None: # getuser() likely returned illegal characters for the # platform, use unknown back off mechanism - rootdir = _try_ensure_directory( - temproot.joinpath("pytest-of-unknown") - ) + rootdir = _try_ensure_directory(temproot.joinpath("pytest-of-unknown")) if rootdir is None: # All predictable names are blocked (e.g. by a non-directory # file we cannot remove). Fall back to a unique directory From 0e3581c638eb077be44efcbbf261ea775f04209a Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 14:53:30 -0400 Subject: [PATCH 23/29] test(tmpdir): add regression test for mkdir failure after unlink in _try_ensure_directory Cover the race-condition branch where `unlink` succeeds but the subsequent `mkdir` still raises an `OSError` (e.g. a concurrent process recreated the path between the two calls). Verifies that `_try_ensure_directory` returns `None` rather than propagating the exception. Co-authored-by: Windsurf --- testing/test_tmpdir.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index bc27ba9747e..d3bdb2f6b8d 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -809,6 +809,24 @@ def test_returns_none_when_unlink_fails( result = _try_ensure_directory(target) assert result is None + def test_returns_none_when_mkdir_fails_after_unlink( + self, tmp_path: Path, monkeypatch: MonkeyPatch + ) -> None: + """unlink succeeds but the subsequent mkdir still raises (e.g. race).""" + target = tmp_path / "blocked" + target.touch() + + original_mkdir = Path.mkdir + + def _fail_mkdir(self: Path, *args: object, **kwargs: object) -> None: + if self == target and not target.exists(): + raise OSError("simulated race: mkdir failed after unlink") + original_mkdir(self, *args, **kwargs) + + monkeypatch.setattr(Path, "mkdir", _fail_mkdir) + result = _try_ensure_directory(target) + assert result is None + def test_returns_none_for_unresolvable_path(self) -> None: result = _try_ensure_directory(Path("/no/such/parent/dir")) assert result is None From d1d6cae6090aa0c74217aa28b5fdad2440fc793d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:53:57 +0000 Subject: [PATCH 24/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_tmpdir.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index d3bdb2f6b8d..0281e845b84 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -812,7 +812,7 @@ def test_returns_none_when_unlink_fails( def test_returns_none_when_mkdir_fails_after_unlink( self, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: - """unlink succeeds but the subsequent mkdir still raises (e.g. race).""" + """Unlink succeeds but the subsequent mkdir still raises (e.g. race).""" target = tmp_path / "blocked" target.touch() From 064b26ed11b1668470a3244fe063fb006a9e4926 Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 15:43:11 -0400 Subject: [PATCH 25/29] tmpdir: replace predictable rootdir with mkdtemp-based random suffix (#13669) Co-authored-by: Windsurf --- src/_pytest/tmpdir.py | 75 ++++++++-------- testing/test_tmpdir.py | 197 ++++++++++++++++++++--------------------- 2 files changed, 134 insertions(+), 138 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 4b4526e541a..9a7615cf872 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -72,33 +72,28 @@ def _safe_open_dir(path: Path) -> Generator[int]: os.close(dir_fd) -def _try_ensure_directory(path: Path) -> Path | None: - """Try to create *path* as a directory (mode 0o700). +def _cleanup_old_rootdirs( + temproot: Path, prefix: str, keep: int, current: Path +) -> None: + """Remove old randomly-named rootdirs, keeping the *keep* most recent. - If a non-directory file is blocking the path (e.g. placed by another user), - attempt to remove it first and retry. Returns the path on success, or - ``None`` when the directory cannot be created. + *current* is excluded so the running session's rootdir is never removed. + Errors are silently ignored (other sessions may hold locks, etc.). """ try: - path.mkdir(mode=0o700, exist_ok=True) - return path + candidates = sorted( + ( + p + for p in temproot.iterdir() + if p.is_dir() and p.name.startswith(prefix) and p != current + ), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) except OSError: - pass - - # A non-directory entry (regular file, socket, …) may be squatting on the - # name. Try to remove it so we can create our directory. - if path.exists() and not path.is_dir(): - try: - path.unlink() - except OSError: - return None - try: - path.mkdir(mode=0o700) - return path - except OSError: - return None - - return None + return + for old in candidates[keep:]: + rmtree(old, ignore_errors=True) @final @@ -221,25 +216,25 @@ def getbasetemp(self) -> Path: user = get_user() or "unknown" # use a sub-directory in the temproot to speed-up # make_numbered_dir() call - rootdir = _try_ensure_directory(temproot.joinpath(f"pytest-of-{user}")) - if rootdir is None: + # Use a randomly-named rootdir created via mkdtemp to avoid + # the entire class of predictable-name attacks (symlink races, + # DoS via pre-created files/dirs, etc.). See #13669. + rootdir_prefix = f"pytest-of-{user}-" + try: + rootdir = Path( + tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot) + ) + except OSError: # getuser() likely returned illegal characters for the - # platform, use unknown back off mechanism - rootdir = _try_ensure_directory(temproot.joinpath("pytest-of-unknown")) - if rootdir is None: - # All predictable names are blocked (e.g. by a non-directory - # file we cannot remove). Fall back to a unique directory - # so that pytest can still function. + # platform, fall back to a safe prefix. + rootdir_prefix = "pytest-of-unknown-" rootdir = Path( - tempfile.mkdtemp(prefix=f"pytest-of-{user}-", dir=temproot) + tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot) ) - os.chmod(rootdir, 0o700) - # Because we use exist_ok=True with a predictable name, make sure - # we are the owners, to prevent any funny business (on unix, where - # temproot is usually shared). - # Also, to keep things private, fixup any world-readable temp - # rootdir's permissions. Historically 0o755 was used, so we can't - # just error out on this, at least for a while. + # mkdtemp applies the umask; ensure 0o700 unconditionally. + os.chmod(rootdir, 0o700) + # Defense-in-depth: verify ownership and tighten permissions + # via fd-based ops to eliminate TOCTOU races (CVE-2025-71176). uid = get_user_id() if uid is not None: with _safe_open_dir(rootdir) as dir_fd: @@ -261,6 +256,8 @@ def getbasetemp(self) -> Path: lock_timeout=LOCK_TIMEOUT, mode=0o700, ) + # Clean up old rootdirs from previous sessions. + _cleanup_old_rootdirs(temproot, rootdir_prefix, keep, current=rootdir) assert basetemp is not None, basetemp self._basetemp = basetemp self._trace("new basetemp", basetemp) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 0281e845b84..487d4298a50 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -8,6 +8,8 @@ from pathlib import Path import stat import sys +import tempfile +from typing import Any from typing import cast import warnings @@ -22,8 +24,8 @@ from _pytest.pathlib import register_cleanup_lock_removal from _pytest.pathlib import rm_rf from _pytest.pytester import Pytester +from _pytest.tmpdir import _cleanup_old_rootdirs from _pytest.tmpdir import _safe_open_dir -from _pytest.tmpdir import _try_ensure_directory from _pytest.tmpdir import get_user from _pytest.tmpdir import pytest_sessionfinish from _pytest.tmpdir import TempPathFactory @@ -601,32 +603,33 @@ def test_tmp_path_factory_create_directory_with_safe_permissions( # No world-readable permissions. assert (basetemp.stat().st_mode & 0o077) == 0 - # Parent too (pytest-of-foo). + # Parent rootdir too (pytest-of--XXXXXX). assert (basetemp.parent.stat().st_mode & 0o077) == 0 @pytest.mark.skipif(not hasattr(os, "getuid"), reason="checks unix permissions") -def test_tmp_path_factory_fixes_up_world_readable_permissions( +def test_tmp_path_factory_defense_in_depth_fchmod( tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: - """Verify that if a /tmp/pytest-of-foo directory already exists with - world-readable permissions, it is fixed. - - pytest used to mkdir with such permissions, that's why we fix it up. - """ - # Use the test's tmp_path as the system temproot (/tmp). + """Defense-in-depth: even if os.chmod after mkdtemp somehow leaves + world-readable permissions, the fchmod inside _safe_open_dir corrects + them.""" monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) - basetemp = tmp_factory.getbasetemp() - # Before - simulate bad perms. - os.chmod(basetemp.parent, 0o777) - assert (basetemp.parent.stat().st_mode & 0o077) != 0 + # Sabotage os.chmod so the initial permission fix is a no-op; + # the fd-based fchmod should still tighten permissions. + original_chmod = os.chmod + + def _noop_chmod(path: Any, mode: int, **kw: Any) -> None: + # Let mkdtemp's internal mkdir work, but skip our explicit fix-up. + pass + + monkeypatch.setattr(os, "chmod", _noop_chmod) tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp() - # After - fixed. + # The fchmod defense should have corrected the permissions. assert (basetemp.parent.stat().st_mode & 0o077) == 0 @@ -634,23 +637,24 @@ def test_tmp_path_factory_fixes_up_world_readable_permissions( def test_tmp_path_factory_rejects_symlink_rootdir( tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: - """CVE-2025-71176: verify that a symlink at the pytest-of- location - is rejected to prevent symlink attacks.""" + """CVE-2025-71176: defense-in-depth — if the mkdtemp-created rootdir is + somehow replaced by a symlink before _safe_open_dir runs, it must be + rejected.""" monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - # Pre-create a target directory that the symlink will point to. attacker_dir = tmp_path / "attacker-controlled" attacker_dir.mkdir(mode=0o700) - # Figure out what rootdir name pytest would use, then replace it - # with a symlink pointing to the attacker-controlled directory. - user = getpass.getuser() - rootdir = tmp_path / f"pytest-of-{user}" + original_mkdtemp = tempfile.mkdtemp + + def _mkdtemp_then_replace_with_symlink(**kwargs: Any) -> str: + """Create the dir normally, then swap it for a symlink.""" + path = original_mkdtemp(**kwargs) + os.rmdir(path) + os.symlink(str(attacker_dir), path) + return path - # Ensure the real dir exists so the cleanup branch is exercised. - rootdir.mkdir(mode=0o700, exist_ok=True) - rootdir.rmdir() - rootdir.symlink_to(attacker_dir) + monkeypatch.setattr(tempfile, "mkdtemp", _mkdtemp_then_replace_with_symlink) tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) with pytest.raises(OSError, match="could not be safely opened"): @@ -771,97 +775,92 @@ class config: pytest_sessionfinish(FakeSession, exitstatus=0) -# -- Unit tests for _try_ensure_directory -- - - -class TestTryEnsureDirectory: - """Tests for _try_ensure_directory which mitigates the DoS where an - attacker pre-creates regular files at /tmp/pytest-of-.""" - - def test_creates_new_directory(self, tmp_path: Path) -> None: - target = tmp_path / "newdir" - result = _try_ensure_directory(target) - assert result == target - assert target.is_dir() - - def test_returns_existing_directory(self, tmp_path: Path) -> None: - target = tmp_path / "existingdir" - target.mkdir(mode=0o700) - result = _try_ensure_directory(target) - assert result == target - assert target.is_dir() - - def test_removes_blocking_file_and_creates_dir(self, tmp_path: Path) -> None: - """If a regular file squats on the name we can remove, mkdir succeeds.""" - target = tmp_path / "blocked" - target.touch() - result = _try_ensure_directory(target) - assert result == target - assert target.is_dir() - - def test_returns_none_when_unlink_fails( - self, tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: - """When we cannot remove the blocking file, return None.""" - target = tmp_path / "blocked" - target.touch() - monkeypatch.setattr(Path, "unlink", _raise_oserror) - result = _try_ensure_directory(target) - assert result is None - - def test_returns_none_when_mkdir_fails_after_unlink( - self, tmp_path: Path, monkeypatch: MonkeyPatch - ) -> None: - """Unlink succeeds but the subsequent mkdir still raises (e.g. race).""" - target = tmp_path / "blocked" - target.touch() - - original_mkdir = Path.mkdir - - def _fail_mkdir(self: Path, *args: object, **kwargs: object) -> None: - if self == target and not target.exists(): - raise OSError("simulated race: mkdir failed after unlink") - original_mkdir(self, *args, **kwargs) +# -- Tests for mkdtemp-based rootdir creation (DoS mitigation, #13669) -- - monkeypatch.setattr(Path, "mkdir", _fail_mkdir) - result = _try_ensure_directory(target) - assert result is None - - def test_returns_none_for_unresolvable_path(self) -> None: - result = _try_ensure_directory(Path("/no/such/parent/dir")) - assert result is None - - -def _raise_oserror(*args: object, **kwargs: object) -> None: - raise OSError("simulated permission denied") +def test_getbasetemp_uses_mkdtemp_rootdir( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + """Verify that getbasetemp always creates a randomly-named rootdir + via mkdtemp, not a predictable pytest-of- directory.""" + monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) + factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) + basetemp = factory.getbasetemp() + user = get_user() or "unknown" + rootdir = basetemp.parent + # The rootdir name should start with the user prefix but have a + # random suffix (from mkdtemp), NOT be exactly "pytest-of-". + assert rootdir.name.startswith(f"pytest-of-{user}-") + assert rootdir.name != f"pytest-of-{user}" + assert rootdir.is_dir() -def test_getbasetemp_falls_back_to_mkdtemp_when_paths_blocked( +def test_getbasetemp_immune_to_predictable_path_dos( tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: - """When both pytest-of- and pytest-of-unknown are blocked by - non-directory files that cannot be removed, getbasetemp falls back to - tempfile.mkdtemp so pytest can still function (DoS mitigation).""" + """An attacker pre-creating files at /tmp/pytest-of- cannot DoS + pytest because we no longer use predictable paths (#13669).""" temproot = tmp_path / "temproot" temproot.mkdir() monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(temproot)) user = get_user() or "unknown" - # Create blocking files for both predictable paths. + # Simulate the attack: touch /tmp/pytest-of-$user for every user. (temproot / f"pytest-of-{user}").touch() - if user != "unknown": - (temproot / "pytest-of-unknown").touch() - - # Make the blocking files undeletable by monkeypatching Path.unlink. - monkeypatch.setattr(Path, "unlink", _raise_oserror) + (temproot / "pytest-of-unknown").touch() factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) basetemp = factory.getbasetemp() assert basetemp.is_dir() - # The rootdir should start with the mkdtemp prefix. + # The blocking files are simply ignored; mkdtemp picks a unique name. assert basetemp.parent.name.startswith(f"pytest-of-{user}-") +# -- Unit tests for _cleanup_old_rootdirs -- + + +class TestCleanupOldRootdirs: + """Tests for cross-session cleanup of mkdtemp-created rootdirs.""" + + def test_removes_excess_rootdirs(self, tmp_path: Path) -> None: + prefix = "pytest-of-testuser-" + dirs = [] + for i in range(5): + d = tmp_path / f"{prefix}{i:08}" + d.mkdir() + dirs.append(d) + + current = dirs[-1] + _cleanup_old_rootdirs(tmp_path, prefix, keep=2, current=current) + + remaining = sorted( + p for p in tmp_path.iterdir() if p.name.startswith(prefix) + ) + # current + 2 most recent old dirs = 3 total + assert len(remaining) == 3 + assert current in remaining + + def test_never_removes_current(self, tmp_path: Path) -> None: + prefix = "pytest-of-testuser-" + current = tmp_path / f"{prefix}only" + current.mkdir() + _cleanup_old_rootdirs(tmp_path, prefix, keep=0, current=current) + assert current.is_dir() + + def test_tolerates_missing_temproot(self) -> None: + _cleanup_old_rootdirs( + Path("/nonexistent"), "pytest-of-x-", keep=3, current=Path("/x") + ) + + def test_ignores_non_matching_entries(self, tmp_path: Path) -> None: + prefix = "pytest-of-testuser-" + current = tmp_path / f"{prefix}current" + current.mkdir() + unrelated = tmp_path / "some-other-dir" + unrelated.mkdir() + _cleanup_old_rootdirs(tmp_path, prefix, keep=0, current=current) + assert unrelated.is_dir() + + # -- Direct unit tests for _safe_open_dir context manager -- From 124027e552c755dabae89b3c25d9ef2b37605f02 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:43:47 +0000 Subject: [PATCH 26/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/tmpdir.py | 8 ++------ testing/test_tmpdir.py | 6 ++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 9a7615cf872..ad7b9bbea01 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -221,16 +221,12 @@ def getbasetemp(self) -> Path: # DoS via pre-created files/dirs, etc.). See #13669. rootdir_prefix = f"pytest-of-{user}-" try: - rootdir = Path( - tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot) - ) + rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot)) except OSError: # getuser() likely returned illegal characters for the # platform, fall back to a safe prefix. rootdir_prefix = "pytest-of-unknown-" - rootdir = Path( - tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot) - ) + rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot)) # mkdtemp applies the umask; ensure 0o700 unconditionally. os.chmod(rootdir, 0o700) # Defense-in-depth: verify ownership and tighten permissions diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 487d4298a50..06d504c13d9 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -3,7 +3,6 @@ from collections.abc import Callable import dataclasses -import getpass import os from pathlib import Path import stat @@ -777,6 +776,7 @@ class config: # -- Tests for mkdtemp-based rootdir creation (DoS mitigation, #13669) -- + def test_getbasetemp_uses_mkdtemp_rootdir( tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: @@ -832,9 +832,7 @@ def test_removes_excess_rootdirs(self, tmp_path: Path) -> None: current = dirs[-1] _cleanup_old_rootdirs(tmp_path, prefix, keep=2, current=current) - remaining = sorted( - p for p in tmp_path.iterdir() if p.name.startswith(prefix) - ) + remaining = sorted(p for p in tmp_path.iterdir() if p.name.startswith(prefix)) # current + 2 most recent old dirs = 3 total assert len(remaining) == 3 assert current in remaining From 654e3dd4e63e2aacd40ebebbadab2b8130977bfb Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 16:00:37 -0400 Subject: [PATCH 27/29] refactor: style: minor code formatting cleanup in tmpdir and test_tmpdir - Remove blank line before tmppath_result_key assignment - Collapse multi-line Path(tempfile.mkdtemp(...)) calls to single lines - Remove unused `getpass` import and `original_chmod` variable in tests - Add explicit `str` type annotation to `path` variable in symlink test - Add blank line before test_getbasetemp_uses_mkdtemp_rootdir function - Collapse multi-line sorted() call in TestCleanupOldRootdirs to single line Co-authored-by: Windsurf --- src/_pytest/tmpdir.py | 9 ++------- testing/test_tmpdir.py | 10 +++------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 9a7615cf872..6d5cc35d94a 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -33,7 +33,6 @@ from _pytest.reports import TestReport from _pytest.stash import StashKey - tmppath_result_key = StashKey[dict[str, bool]]() RetentionType = Literal["all", "failed", "none"] @@ -221,16 +220,12 @@ def getbasetemp(self) -> Path: # DoS via pre-created files/dirs, etc.). See #13669. rootdir_prefix = f"pytest-of-{user}-" try: - rootdir = Path( - tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot) - ) + rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot)) except OSError: # getuser() likely returned illegal characters for the # platform, fall back to a safe prefix. rootdir_prefix = "pytest-of-unknown-" - rootdir = Path( - tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot) - ) + rootdir = Path(tempfile.mkdtemp(prefix=rootdir_prefix, dir=temproot)) # mkdtemp applies the umask; ensure 0o700 unconditionally. os.chmod(rootdir, 0o700) # Defense-in-depth: verify ownership and tighten permissions diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 487d4298a50..f7732e97d70 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -3,7 +3,6 @@ from collections.abc import Callable import dataclasses -import getpass import os from pathlib import Path import stat @@ -618,8 +617,6 @@ def test_tmp_path_factory_defense_in_depth_fchmod( # Sabotage os.chmod so the initial permission fix is a no-op; # the fd-based fchmod should still tighten permissions. - original_chmod = os.chmod - def _noop_chmod(path: Any, mode: int, **kw: Any) -> None: # Let mkdtemp's internal mkdir work, but skip our explicit fix-up. pass @@ -649,7 +646,7 @@ def test_tmp_path_factory_rejects_symlink_rootdir( def _mkdtemp_then_replace_with_symlink(**kwargs: Any) -> str: """Create the dir normally, then swap it for a symlink.""" - path = original_mkdtemp(**kwargs) + path: str = original_mkdtemp(**kwargs) os.rmdir(path) os.symlink(str(attacker_dir), path) return path @@ -777,6 +774,7 @@ class config: # -- Tests for mkdtemp-based rootdir creation (DoS mitigation, #13669) -- + def test_getbasetemp_uses_mkdtemp_rootdir( tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: @@ -832,9 +830,7 @@ def test_removes_excess_rootdirs(self, tmp_path: Path) -> None: current = dirs[-1] _cleanup_old_rootdirs(tmp_path, prefix, keep=2, current=current) - remaining = sorted( - p for p in tmp_path.iterdir() if p.name.startswith(prefix) - ) + remaining = sorted(p for p in tmp_path.iterdir() if p.name.startswith(prefix)) # current + 2 most recent old dirs = 3 total assert len(remaining) == 3 assert current in remaining From 43aa2c8b0ec417c1926476cc24f2f44041066a74 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:01:01 +0000 Subject: [PATCH 28/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/tmpdir.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/_pytest/tmpdir.py b/src/_pytest/tmpdir.py index 6d5cc35d94a..ad7b9bbea01 100644 --- a/src/_pytest/tmpdir.py +++ b/src/_pytest/tmpdir.py @@ -33,6 +33,7 @@ from _pytest.reports import TestReport from _pytest.stash import StashKey + tmppath_result_key = StashKey[dict[str, bool]]() RetentionType = Literal["all", "failed", "none"] From e696db0ca9aebf1d335b40c48d942882ab57bcdb Mon Sep 17 00:00:00 2001 From: laura Date: Sun, 15 Mar 2026 16:37:04 -0400 Subject: [PATCH 29/29] test(tmpdir): strengthen fchmod defense-in-depth test by widening permissions instead of no-op Replace the `_noop_chmod` stub (which simply skipped our chmod call) with `_widen_chmod`, which actively sets permissions to 0o755. This makes the test more realistic: `fchmod` must now *correct* an adversarially-widened directory, not merely compensate for a missing tightening step. Co-authored-by: Windsurf --- testing/test_tmpdir.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index f7732e97d70..ddcda79bfb5 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -615,13 +615,14 @@ def test_tmp_path_factory_defense_in_depth_fchmod( them.""" monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(tmp_path)) - # Sabotage os.chmod so the initial permission fix is a no-op; - # the fd-based fchmod should still tighten permissions. - def _noop_chmod(path: Any, mode: int, **kw: Any) -> None: - # Let mkdtemp's internal mkdir work, but skip our explicit fix-up. - pass + # Sabotage os.chmod so it *widens* permissions instead of tightening; + # the fd-based fchmod should still correct them. + original_chmod = os.chmod - monkeypatch.setattr(os, "chmod", _noop_chmod) + def _widen_chmod(path: Any, mode: int, **kw: Any) -> None: + original_chmod(path, 0o755) + + monkeypatch.setattr(os, "chmod", _widen_chmod) tmp_factory = TempPathFactory(None, 3, "all", lambda *args: None, _ispytest=True) basetemp = tmp_factory.getbasetemp()