diff --git a/backend/src/hatchling/builders/utils.py b/backend/src/hatchling/builders/utils.py index f462073b8..72a578907 100644 --- a/backend/src/hatchling/builders/utils.py +++ b/backend/src/hatchling/builders/utils.py @@ -2,6 +2,7 @@ import os import shutil +import stat from base64 import urlsafe_b64encode from typing import TYPE_CHECKING @@ -19,11 +20,49 @@ def replace_file(src: str, dst: str) -> None: os.remove(src) +def _is_dir_link(path: str) -> bool: + try: + st = os.lstat(path) + except OSError: + return False + if stat.S_ISLNK(st.st_mode): + return True + # Windows junctions are reparse points; ``os.path.islink`` is false for them + # on some Python versions. + file_attributes = getattr(st, "st_file_attributes", 0) + return bool(file_attributes & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + + +def _is_inside_directory(root: str, path: str) -> bool: + try: + common = os.path.commonpath([root, path]) + except ValueError: + return False + return os.path.normcase(common) == os.path.normcase(root) + + def safe_walk(path: str) -> Iterable[tuple[str, list[str], list[str]]]: seen = set() + walk_root = os.path.realpath(path) + walk_root_abs = os.path.abspath(path) for root, dirs, files in os.walk(path, followlinks=True): - stat = os.stat(root) - identifier = stat.st_dev, stat.st_ino + # Following an in-tree directory link records the target inode as seen. + # If the alias sorts first, the real directory is then skipped as a cycle. + if ( + _is_dir_link(root) + and os.path.normcase(os.path.abspath(root)) != os.path.normcase(walk_root_abs) + and _is_inside_directory(walk_root, os.path.realpath(root)) + ): + del dirs[:] + continue + + try: + root_stat = os.stat(root) + except OSError: + del dirs[:] + continue + + identifier = root_stat.st_dev, root_stat.st_ino if identifier in seen: del dirs[:] continue diff --git a/tests/backend/builders/plugin/test_interface.py b/tests/backend/builders/plugin/test_interface.py index 6af095444..a2a0a826d 100644 --- a/tests/backend/builders/plugin/test_interface.py +++ b/tests/backend/builders/plugin/test_interface.py @@ -166,6 +166,53 @@ def test_infinite_loop_prevention(self, temp_dir): str(project_dir / "foo" / "bar.txt"), ] + def test_in_tree_dir_link_does_not_hide_real_directory(self, temp_dir): + """A directory link that sorts before its target must not drop the real package.""" + project_dir = temp_dir / "project" + project_dir.ensure_dir_exists() + + foo = project_dir / "foo" + foo.ensure_dir_exists() + (foo / "__init__.py").touch() + (foo / "pkg.py").touch() + + bar = project_dir / "bar" + try: + bar.symlink_to(foo, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlink/junction not available: {exc}") + + with project_dir.as_cwd(): + config = {"tool": {"hatch": {"build": {"include": ["foo"]}}}} + builder = MockBuilder(str(project_dir), config=config) + + relative_paths = sorted(f.relative_path.replace("\\", "/") for f in builder.recurse_included_files()) + + assert relative_paths == ["foo/__init__.py", "foo/pkg.py"] + + def test_excluding_dir_link_keeps_real_directory(self, temp_dir): + """Excluding the alias must not exclude the real package.""" + project_dir = temp_dir / "project" + project_dir.ensure_dir_exists() + + foo = project_dir / "foo" + foo.ensure_dir_exists() + (foo / "__init__.py").touch() + + bar = project_dir / "bar" + try: + bar.symlink_to(foo, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlink/junction not available: {exc}") + + with project_dir.as_cwd(): + config = {"tool": {"hatch": {"build": {"include": ["foo", "bar"], "exclude": ["bar"]}}}} + builder = MockBuilder(str(project_dir), config=config) + + relative_paths = sorted(f.relative_path.replace("\\", "/") for f in builder.recurse_included_files()) + + assert relative_paths == ["foo/__init__.py"] + def test_only_include(self, temp_dir): project_dir = temp_dir / "project" project_dir.ensure_dir_exists()