diff --git a/src/pyproject_api/_frontend.py b/src/pyproject_api/_frontend.py index 9356c2a..87616d8 100644 --- a/src/pyproject_api/_frontend.py +++ b/src/pyproject_api/_frontend.py @@ -211,7 +211,8 @@ def create_args_from_folder( :param folder: the python project folder :return: the frontend creation args - :raises ValueError: if ``build-system`` is not a table, or one of its keys has a type PEP-518 rules out + :raises ValueError: if ``build-system`` is not a table, one of its keys has a type PEP-518 rules out, or a + ``backend-path`` entry does not resolve inside the project (PEP-517 in-tree backends) E.g., to create a frontend from a python project folder: @@ -228,7 +229,9 @@ def create_args_from_folder( msg = f"build-system must be a table, got {build_system!r}" raise ValueError(msg) if "backend-path" in build_system: - backend_paths: tuple[Path, ...] = tuple(folder / p for p in _str_list(build_system, "backend-path")) + backend_paths: tuple[Path, ...] = tuple( + _in_tree_path(folder, entry) for entry in _str_list(build_system, "backend-path") + ) else: backend_paths = () if "requires" in build_system: @@ -539,6 +542,19 @@ def _str_list(build_system: dict[str, Any], key: str) -> list[str]: return value +def _in_tree_path(folder: Path, entry: str) -> Path: + path = folder / entry + # Python 3.15 on Windows resolves a path containing NUL instead of raising + try: + inside = "\0" not in entry and not Path(entry).is_absolute() and path.resolve().is_relative_to(folder.resolve()) + except (OSError, RuntimeError): # RuntimeError: symlink loop before Python 3.13 + inside = False + if not inside: + msg = f"build-system.backend-path entry {entry!r} must be a relative path inside the project {folder}" + raise ValueError(msg) + return path + + __all__ = [ "BackendFailed", "CmdStatus", diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f6172d2..4038de0 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -16,6 +16,8 @@ if TYPE_CHECKING: from collections.abc import Callable + import pytest_mock + @pytest.fixture def local_builder(tmp_path: Path) -> Callable[[str], Path]: @@ -183,6 +185,38 @@ def test_empty_pyproject(tmp_path: Path) -> None: assert str(left) == str(right) +@pytest.mark.parametrize( + "backend_path", + [ + pytest.param("../outside", id="parent"), + pytest.param("{tmp}/outside", id="absolute-outside"), + pytest.param("{tmp}/project", id="absolute-inside"), + pytest.param("link", id="symlink-outside"), + pytest.param("\\u0000", id="nul"), + ], +) +def test_create_args_from_folder_rejects_out_of_tree_backend_path(tmp_path: Path, backend_path: str) -> None: + project = tmp_path / "project" + project.mkdir() + (project / "link").symlink_to(tmp_path, target_is_directory=True) + (project / "pyproject.toml").write_text( + f'[build-system]\nbackend-path = ["{backend_path.format(tmp=tmp_path.as_posix())}"]\nbuild-backend = "b"\n' + ) + + with pytest.raises(ValueError, match=r"backend-path entry .* must be a relative path inside the project"): + SubprocessFrontend.create_args_from_folder(project) + + +def test_create_args_from_folder_rejects_unresolvable_backend_path( + tmp_path: Path, mocker: pytest_mock.MockerFixture +) -> None: + (tmp_path / "pyproject.toml").write_text('[build-system]\nbackend-path = ["."]\nbuild-backend = "b"\n') + mocker.patch.object(Path, "resolve", autospec=True, side_effect=RuntimeError("Symlink loop")) + + with pytest.raises(ValueError, match=r"backend-path entry '\.' must be a relative path inside the project"): + SubprocessFrontend.create_args_from_folder(tmp_path) + + @pytest.fixture(scope="session") def demo_pkg_inline() -> Path: return Path(__file__).absolute().parent / "demo_pkg_inline"