diff --git a/AUTHORS b/AUTHORS index b59ebc2a27f..b21422a969d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -221,6 +221,7 @@ Pedro Algarvio Philipp Loose Pieter Mulder Piotr Banaszkiewicz +Prashant Anand Pulkit Goyal Punyashloka Biswal Quentin Pradet diff --git a/changelog/7119.improvement.rst b/changelog/7119.improvement.rst new file mode 100644 index 00000000000..6cef9883633 --- /dev/null +++ b/changelog/7119.improvement.rst @@ -0,0 +1,2 @@ +Exit with an error if the ``--basetemp`` argument is empty, the current working directory or parent directory of it. +This is done to protect against accidental data loss, as any directory passed to this argument is cleared. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 61eb7ca74c2..d00d286bade 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -1,4 +1,5 @@ """ core implementation of testing process: init, session, runtest loop. """ +import argparse import fnmatch import functools import importlib @@ -26,6 +27,7 @@ from _pytest.config import UsageError from _pytest.fixtures import FixtureManager from _pytest.outcomes import exit +from _pytest.pathlib import Path from _pytest.reports import CollectReport from _pytest.runner import collect_one_node from _pytest.runner import SetupState @@ -167,6 +169,7 @@ def pytest_addoption(parser): "--basetemp", dest="basetemp", default=None, + type=validate_basetemp, metavar="dir", help=( "base temporary directory for this test run." @@ -175,6 +178,34 @@ def pytest_addoption(parser): ) +def validate_basetemp(path: str) -> str: + # GH 7119 + msg = "basetemp must not be empty, the current working directory or any parent directory of it" + + # empty path + if not path: + raise argparse.ArgumentTypeError(msg) + + def is_ancestor(base: Path, query: Path) -> bool: + """ return True if query is an ancestor of base, else False.""" + if base == query: + return True + for parent in base.parents: + if parent == query: + return True + return False + + # check if path is an ancestor of cwd + if is_ancestor(Path.cwd(), Path(path).absolute()): + raise argparse.ArgumentTypeError(msg) + + # check symlinks for ancestors + if is_ancestor(Path.cwd().resolve(), Path(path).resolve()): + raise argparse.ArgumentTypeError(msg) + + return path + + def wrap_session( config: Config, doit: Callable[[Config, "Session"], Optional[Union[int, ExitCode]]] ) -> Union[int, ExitCode]: diff --git a/testing/test_main.py b/testing/test_main.py index 07aca3a1e24..ee8349a9f33 100644 --- a/testing/test_main.py +++ b/testing/test_main.py @@ -1,7 +1,9 @@ +import argparse from typing import Optional import pytest from _pytest.config import ExitCode +from _pytest.main import validate_basetemp from _pytest.pytester import Testdir @@ -75,3 +77,24 @@ def pytest_sessionfinish(): assert result.ret == ExitCode.NO_TESTS_COLLECTED assert result.stdout.lines[-1] == "collected 0 items" assert result.stderr.lines == ["Exit: exit_pytest_sessionfinish"] + + +@pytest.mark.parametrize("basetemp", ["foo", "foo/bar"]) +def test_validate_basetemp_ok(tmp_path, basetemp, monkeypatch): + monkeypatch.chdir(str(tmp_path)) + validate_basetemp(tmp_path / basetemp) + + +@pytest.mark.parametrize("basetemp", ["", ".", ".."]) +def test_validate_basetemp_fails(tmp_path, basetemp, monkeypatch): + monkeypatch.chdir(str(tmp_path)) + msg = "basetemp must not be empty, the current working directory or any parent directory of it" + with pytest.raises(argparse.ArgumentTypeError, match=msg): + if basetemp: + basetemp = tmp_path / basetemp + validate_basetemp(basetemp) + + +def test_validate_basetemp_integration(testdir): + result = testdir.runpytest("--basetemp=.") + result.stderr.fnmatch_lines("*basetemp must not be*")