Repository: psf/black

Issue:
pathlib exception when symlinks are involved
**Describe the bug**

Hello! First of all, thank you for this invaluable tool.

I first ran into this issue on Windows when using black to format code located on a mapped network drive. 

This line in [get_sources()](https://github.com/psf/black/blob/a0e270d0f246387202e676b25abbf7a02ddcbc71/src/black/__init__.py#L656C17-L656C17) doesn't ignore symlinks pointing outside of `root`, which has symlinks resolved by [find_project_root()](https://github.com/psf/black/blob/a0e270d0f246387202e676b25abbf7a02ddcbc71/src/black/files.py#L46):
https://github.com/psf/black/blob/a0e270d0f246387202e676b25abbf7a02ddcbc71/src/black/__init__.py#L687

**To Reproduce on Linux**

```shell
mkdir test_black
ln -s test_black test_black_sym
cd test_black
git init
echo "print('hello')" > app.py
black ~/test_black_sym/app.py
```

`git init` is needed so that [find_project_root()](https://github.com/psf/black/blob/main/src/black/files.py#L46) doesn't return the root of the file system. 

The resulting error is:

```shell
Traceback (most recent call last):
  <some omitted>
  File "/home/tg2648/black/src/black/__init__.py", line 600, in main
    sources = get_sources(
              ^^^^^^^^^^^^
  File "/home/tg2648/black/src/black/__init__.py", line 687, in get_sources
    root_relative_path = path.absolute().relative_to(root).as_posix()
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/tg2648/.pyenv/versions/3.11.0/lib/python3.11/pathlib.py", line 730, in relative_to
    raise ValueError("{!r} is not in the subpath of {!r}"
ValueError: '/home/tg2648/test_black_sym/app.py' is not in the subpath of '/home/tg2648/test_black' OR one path is relative and the other is absolute.
```

**Environment**

<!-- Please complete the following information: -->

- Black's version: 23.11.0
- OS and Python version: Windows and Linux / Python 3.11.0

**Ideas**

[normalize_path_maybe_ignore()](https://github.com/psf/black/blob/main/src/black/files.py#L250), which is used later in `get_sources()`, also calculates the root relative path but catches the `ValueError` exception:

https://github.com/psf/black/blob/46be1f8e54ac9a7d67723c0fa28c7bec13a0a2bf/src/black/files.py#L262-L269

Maybe one solution is to extract these lines into a function like `get_root_relative_path` and then use it in `get_sources()` and `normalize_path_maybe_ignore()`. I can submit a PR if this sounds reasonable.


Comments:

Patch:
diff --git a/.github/workflows/diff_shades.yml b/.github/workflows/diff_shades.yml
index 8d8be25..0e1aab0 100644
--- a/.github/workflows/diff_shades.yml
+++ b/.github/workflows/diff_shades.yml
@@ -72,7 +72,7 @@ jobs:
 
       - name: Attempt to use cached baseline analysis
         id: baseline-cache
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         with:
           path: ${{ matrix.baseline-analysis }}
           key: ${{ matrix.baseline-cache-key }}
diff --git a/CHANGES.md b/CHANGES.md
index 1e75fb5..f29834a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -29,6 +29,8 @@
 
 <!-- Changes to how Black can be configured -->
 
+- Fix symlink handling, properly catch and ignore symlinks that point outside of root
+  (#4161)
 - Fix cache mtime logic that resulted in false positive cache hits (#4128)
 
 ### Packaging
diff --git a/src/black/__init__.py b/src/black/__init__.py
index 735ba71..e3cbaab 100644
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -49,6 +49,7 @@ from black.files import (
     find_user_pyproject_toml,
     gen_python_files,
     get_gitignore,
+    get_root_relative_path,
     normalize_path_maybe_ignore,
     parse_pyproject_toml,
     path_is_excluded,
@@ -700,7 +701,10 @@ def get_sources(
 
         # Compare the logic here to the logic in `gen_python_files`.
         if is_stdin or path.is_file():
-            root_relative_path = path.absolute().relative_to(root).as_posix()
+            root_relative_path = get_root_relative_path(path, root, report)
+
+            if root_relative_path is None:
+                continue
 
             root_relative_path = "/" + root_relative_path
 
diff --git a/src/black/files.py b/src/black/files.py
index 858303c..65951ef 100644
--- a/src/black/files.py
+++ b/src/black/files.py
@@ -259,14 +259,7 @@ def normalize_path_maybe_ignore(
     try:
         abspath = path if path.is_absolute() else Path.cwd() / path
         normalized_path = abspath.resolve()
-        try:
-            root_relative_path = normalized_path.relative_to(root).as_posix()
-        except ValueError:
-            if report:
-                report.path_ignored(
-                    path, f"is a symbolic link that points outside {root}"
-                )
-            return None
+        root_relative_path = get_root_relative_path(normalized_path, root, report)
 
     except OSError as e:
         if report:
@@ -276,6 +269,21 @@ def normalize_path_maybe_ignore(
     return root_relative_path
 
 
+def get_root_relative_path(
+    path: Path,
+    root: Path,
+    report: Optional[Report] = None,
+) -> Optional[str]:
+    """Returns the file path relative to the 'root' directory"""
+    try:
+        root_relative_path = path.absolute().relative_to(root).as_posix()
+    except ValueError:
+        if report:
+            report.path_ignored(path, f"is a symbolic link that points outside {root}")
+        return None
+    return root_relative_path
+
+
 def _path_is_ignored(
     root_relative_path: str,
     root: Path,


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
index 0af5fd2..2b5fab5 100644
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -2592,6 +2592,20 @@ class TestFileCollection:
         outside_root_symlink.resolve.assert_called_once()
         ignored_symlink.resolve.assert_not_called()
 
+    def test_get_sources_with_stdin_symlink_outside_root(
+        self,
+    ) -> None:
+        path = THIS_DIR / "data" / "include_exclude_tests"
+        stdin_filename = str(path / "b/exclude/a.py")
+        outside_root_symlink = Path("/target_directory/a.py")
+        with patch("pathlib.Path.resolve", return_value=outside_root_symlink):
+            assert_collected_sources(
+                root=Path("target_directory/"),
+                src=["-"],
+                expected=[],
+                stdin_filename=stdin_filename,
+            )
+
     @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin(self) -> None:
         src = ["-"]