From 6bfcb18b101f87ca6ec0ee743b871d5954c41f90 Mon Sep 17 00:00:00 2001 From: blouirts123 <201396345+blouirts123@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:18:18 +0530 Subject: [PATCH 1/2] Validate in-tree backend paths when reading project configuration --- src/pyproject_api/_frontend.py | 23 +++++++++++++++-- tests/test_frontend.py | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/pyproject_api/_frontend.py b/src/pyproject_api/_frontend.py index 9356c2a..131c6e3 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 is absolute, contains a NUL character, or resolves outside the project root E.g., to create a frontend from a python project folder: @@ -228,7 +229,25 @@ 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_path_values = _str_list(build_system, "backend-path") + backend_paths_list: list[Path] = [] + root = folder.resolve() + for value in backend_path_values: + path = folder / value + try: + valid = ( + "\0" not in value and not Path(value).is_absolute() and path.resolve().is_relative_to(root) + ) + except (OSError, RuntimeError, ValueError): + valid = False + if not valid: + msg = ( + f"build-system.backend-path entry {value!r} must be a relative path without NUL " + f"that resolves within project root {folder}" + ) + raise ValueError(msg) + backend_paths_list.append(path) + backend_paths = tuple(backend_paths_list) else: backend_paths = () if "requires" in build_system: diff --git a/tests/test_frontend.py b/tests/test_frontend.py index f6172d2..4c589ec 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -183,6 +183,52 @@ def test_empty_pyproject(tmp_path: Path) -> None: assert str(left) == str(right) +@pytest.mark.parametrize("path_kind", ["parent", "absolute-outside", "absolute-inside", "embedded-nul"]) +def test_create_args_from_folder_rejects_invalid_backend_path(tmp_path: Path, path_kind: str) -> None: + project = tmp_path / "project" + outside = tmp_path / "outside" + project.mkdir() + outside.mkdir() + inside = project / "backend" + inside.mkdir() + backend_path = { + "parent": "../outside", + "absolute-outside": outside.as_posix(), + "absolute-inside": inside.as_posix(), + "embedded-nul": "\\u0000", + }[path_kind] + (project / "pyproject.toml").write_text( + f'[build-system]\nbackend-path = ["{backend_path}"]\nbuild-backend = "outside_backend"\n', + ) + + with pytest.raises( + ValueError, match=r"build-system\.backend-path.*must be a relative path without NUL.*project root" + ): + SubprocessFrontend.create_args_from_folder(project) + + +def test_create_args_from_folder_wraps_backend_path_resolution_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[build-system]\nbackend-path = ["backend"]\nbuild-backend = "outside_backend"\n', + ) + backend_path = tmp_path / "backend" + original_resolve = Path.resolve + + def fail_backend_path_resolution(path: Path, strict: bool = False) -> Path: + if path == backend_path: + msg = "synthetic path resolution failure" + raise OSError(msg) + return original_resolve(path, strict=strict) + + monkeypatch.setattr(Path, "resolve", fail_backend_path_resolution) + with pytest.raises( + ValueError, match=r"build-system\.backend-path.*must be a relative path without NUL.*project root" + ): + SubprocessFrontend.create_args_from_folder(tmp_path) + + @pytest.fixture(scope="session") def demo_pkg_inline() -> Path: return Path(__file__).absolute().parent / "demo_pkg_inline" From 076d9bdd79b3a111a598c0a060e1a6263dd38f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 15 Oct 2026 18:13:54 -0700 Subject: [PATCH 2/2] refactor(frontend): extract in-tree backend path check Move the backend-path validation into a helper so the config reader keeps its one-line tuple build. Resolve the project root inside the try, so a resolution failure there also surfaces as the documented ValueError, and drop ValueError from the caught types: the explicit NUL check covers it. Python 3.15 on Windows resolves a NUL-containing path without raising, so that check has to stay. Tests: table-drive the rejected entries with pytest.param ids, add a symlink pointing outside the project, and patch Path.resolve with an autospec mock instead of a hand-written replacement. --- src/pyproject_api/_frontend.py | 37 +++++++++++----------- tests/test_frontend.py | 56 +++++++++++++--------------------- 2 files changed, 39 insertions(+), 54 deletions(-) diff --git a/src/pyproject_api/_frontend.py b/src/pyproject_api/_frontend.py index 131c6e3..87616d8 100644 --- a/src/pyproject_api/_frontend.py +++ b/src/pyproject_api/_frontend.py @@ -212,7 +212,7 @@ 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, one of its keys has a type PEP-518 rules out, or a - backend path is absolute, contains a NUL character, or resolves outside the project root + ``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: @@ -229,25 +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_path_values = _str_list(build_system, "backend-path") - backend_paths_list: list[Path] = [] - root = folder.resolve() - for value in backend_path_values: - path = folder / value - try: - valid = ( - "\0" not in value and not Path(value).is_absolute() and path.resolve().is_relative_to(root) - ) - except (OSError, RuntimeError, ValueError): - valid = False - if not valid: - msg = ( - f"build-system.backend-path entry {value!r} must be a relative path without NUL " - f"that resolves within project root {folder}" - ) - raise ValueError(msg) - backend_paths_list.append(path) - backend_paths = tuple(backend_paths_list) + 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: @@ -558,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 4c589ec..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,49 +185,35 @@ def test_empty_pyproject(tmp_path: Path) -> None: assert str(left) == str(right) -@pytest.mark.parametrize("path_kind", ["parent", "absolute-outside", "absolute-inside", "embedded-nul"]) -def test_create_args_from_folder_rejects_invalid_backend_path(tmp_path: Path, path_kind: str) -> None: +@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" - outside = tmp_path / "outside" project.mkdir() - outside.mkdir() - inside = project / "backend" - inside.mkdir() - backend_path = { - "parent": "../outside", - "absolute-outside": outside.as_posix(), - "absolute-inside": inside.as_posix(), - "embedded-nul": "\\u0000", - }[path_kind] + (project / "link").symlink_to(tmp_path, target_is_directory=True) (project / "pyproject.toml").write_text( - f'[build-system]\nbackend-path = ["{backend_path}"]\nbuild-backend = "outside_backend"\n', + f'[build-system]\nbackend-path = ["{backend_path.format(tmp=tmp_path.as_posix())}"]\nbuild-backend = "b"\n' ) - with pytest.raises( - ValueError, match=r"build-system\.backend-path.*must be a relative path without NUL.*project root" - ): + 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_wraps_backend_path_resolution_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +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 = ["backend"]\nbuild-backend = "outside_backend"\n', - ) - backend_path = tmp_path / "backend" - original_resolve = Path.resolve - - def fail_backend_path_resolution(path: Path, strict: bool = False) -> Path: - if path == backend_path: - msg = "synthetic path resolution failure" - raise OSError(msg) - return original_resolve(path, strict=strict) - - monkeypatch.setattr(Path, "resolve", fail_backend_path_resolution) - with pytest.raises( - ValueError, match=r"build-system\.backend-path.*must be a relative path without NUL.*project root" - ): + (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)