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,

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 = ["-"]

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.

tests/test_black.py::TestFileCollection::test_get_sources_with_stdin_symlink_outside_root
tests/test_black.py::BlackTestCase::test_assertFormatEqual_print_full_tree tests/test_black.py::BlackTestCase::test_assertFormatEqual_print_tree_diff tests/test_black.py::BlackTestCase::test_assert_equivalent_different_asts tests/test_black.py::BlackTestCase::test_async_as_identifier tests/test_black.py::BlackTestCase::test_bpo_2142_workaround tests/test_black.py::BlackTestCase::test_bpo_33660_workaround tests/test_black.py::BlackTestCase::test_broken_symlink tests/test_black.py::BlackTestCase::test_check_diff_use_together tests/test_black.py::BlackTestCase::test_code_option tests/test_black.py::BlackTestCase::test_code_option_changed tests/test_black.py::BlackTestCase::test_code_option_check tests/test_black.py::BlackTestCase::test_code_option_check_changed tests/test_black.py::BlackTestCase::test_code_option_color_diff tests/test_black.py::BlackTestCase::test_code_option_config tests/test_black.py::BlackTestCase::test_code_option_diff tests/test_black.py::BlackTestCase::test_code_option_fast tests/test_black.py::BlackTestCase::test_code_option_parent_config tests/test_black.py::BlackTestCase::test_code_option_safe tests/test_black.py::BlackTestCase::test_debug_visitor tests/test_black.py::BlackTestCase::test_detect_debug_f_strings tests/test_black.py::BlackTestCase::test_detect_pos_only_arguments tests/test_black.py::BlackTestCase::test_empty_ff tests/test_black.py::BlackTestCase::test_endmarker tests/test_black.py::BlackTestCase::test_equivalency_ast_parse_failure_includes_error tests/test_black.py::BlackTestCase::test_experimental_string_processing_warns tests/test_black.py::BlackTestCase::test_expression_diff tests/test_black.py::BlackTestCase::test_expression_diff_with_color tests/test_black.py::BlackTestCase::test_expression_ff tests/test_black.py::BlackTestCase::test_false_positive_symlink_output_issue_3384 tests/test_black.py::BlackTestCase::test_find_project_root tests/test_black.py::BlackTestCase::test_find_pyproject_toml tests/test_black.py::BlackTestCase::test_find_user_pyproject_toml_linux tests/test_black.py::BlackTestCase::test_find_user_pyproject_toml_windows tests/test_black.py::BlackTestCase::test_for_handled_unexpected_eof_error tests/test_black.py::BlackTestCase::test_format_file_contents tests/test_black.py::BlackTestCase::test_get_features_used tests/test_black.py::BlackTestCase::test_get_features_used_decorator tests/test_black.py::BlackTestCase::test_get_features_used_for_future_flags tests/test_black.py::BlackTestCase::test_get_future_imports tests/test_black.py::BlackTestCase::test_infer_target_version tests/test_black.py::BlackTestCase::test_invalid_cli_regex tests/test_black.py::BlackTestCase::test_invalid_config_return_code tests/test_black.py::BlackTestCase::test_lib2to3_parse tests/test_black.py::BlackTestCase::test_line_ranges_in_pyproject_toml tests/test_black.py::BlackTestCase::test_line_ranges_with_code_option tests/test_black.py::BlackTestCase::test_line_ranges_with_ipynb tests/test_black.py::BlackTestCase::test_line_ranges_with_multiple_sources tests/test_black.py::BlackTestCase::test_line_ranges_with_source tests/test_black.py::BlackTestCase::test_line_ranges_with_stdin tests/test_black.py::BlackTestCase::test_multi_file_force_py36 tests/test_black.py::BlackTestCase::test_multi_file_force_pyi tests/test_black.py::BlackTestCase::test_newline_comment_interaction tests/test_black.py::BlackTestCase::test_no_src_fails tests/test_black.py::BlackTestCase::test_normalize_line_endings tests/test_black.py::BlackTestCase::test_normalize_path_ignore_windows_junctions_outside_of_root tests/test_black.py::BlackTestCase::test_one_empty_line tests/test_black.py::BlackTestCase::test_one_empty_line_ff tests/test_black.py::BlackTestCase::test_parse_pyproject_toml tests/test_black.py::BlackTestCase::test_parse_pyproject_toml_project_metadata tests/test_black.py::BlackTestCase::test_pep_572_version_detection tests/test_black.py::BlackTestCase::test_pep_695_version_detection tests/test_black.py::BlackTestCase::test_pipe_force_py36 tests/test_black.py::BlackTestCase::test_pipe_force_pyi tests/test_black.py::BlackTestCase::test_piping tests/test_black.py::BlackTestCase::test_piping_diff tests/test_black.py::BlackTestCase::test_piping_diff_with_color tests/test_black.py::BlackTestCase::test_preserves_line_endings tests/test_black.py::BlackTestCase::test_preserves_line_endings_via_stdin tests/test_black.py::BlackTestCase::test_python37 tests/test_black.py::BlackTestCase::test_read_pyproject_toml tests/test_black.py::BlackTestCase::test_read_pyproject_toml_from_stdin tests/test_black.py::BlackTestCase::test_reformat_one_with_stdin tests/test_black.py::BlackTestCase::test_reformat_one_with_stdin_and_existing_path tests/test_black.py::BlackTestCase::test_reformat_one_with_stdin_empty tests/test_black.py::BlackTestCase::test_reformat_one_with_stdin_filename tests/test_black.py::BlackTestCase::test_reformat_one_with_stdin_filename_ipynb tests/test_black.py::BlackTestCase::test_reformat_one_with_stdin_filename_pyi tests/test_black.py::BlackTestCase::test_report_normal tests/test_black.py::BlackTestCase::test_report_quiet tests/test_black.py::BlackTestCase::test_report_verbose tests/test_black.py::BlackTestCase::test_required_version_does_not_match_on_minor_version tests/test_black.py::BlackTestCase::test_required_version_does_not_match_version tests/test_black.py::BlackTestCase::test_required_version_matches_partial_version tests/test_black.py::BlackTestCase::test_required_version_matches_version tests/test_black.py::BlackTestCase::test_root_logger_not_used_directly tests/test_black.py::BlackTestCase::test_single_file_force_py36 tests/test_black.py::BlackTestCase::test_single_file_force_pyi tests/test_black.py::BlackTestCase::test_skip_magic_trailing_comma tests/test_black.py::BlackTestCase::test_skip_source_first_line tests/test_black.py::BlackTestCase::test_skip_source_first_line_when_mixing_newlines tests/test_black.py::BlackTestCase::test_src_and_code_fails tests/test_black.py::BlackTestCase::test_string_quotes tests/test_black.py::BlackTestCase::test_tab_comment_indentation tests/test_black.py::BlackTestCase::test_works_in_mono_process_only_environment tests/test_black.py::TestCaching::test_get_cache_dir tests/test_black.py::TestCaching::test_cache_broken_file tests/test_black.py::TestCaching::test_cache_single_file_already_cached tests/test_black.py::TestCaching::test_cache_multiple_files tests/test_black.py::TestCaching::test_no_cache_when_writeback_diff[no-color] tests/test_black.py::TestCaching::test_no_cache_when_writeback_diff[with-color] tests/test_black.py::TestCaching::test_output_locking_when_writeback_diff[no-color] tests/test_black.py::TestCaching::test_output_locking_when_writeback_diff[with-color] tests/test_black.py::TestCaching::test_no_cache_when_stdin tests/test_black.py::TestCaching::test_read_cache_no_cachefile tests/test_black.py::TestCaching::test_write_cache_read_cache tests/test_black.py::TestCaching::test_filter_cached tests/test_black.py::TestCaching::test_filter_cached_hash tests/test_black.py::TestCaching::test_write_cache_creates_directory_if_needed tests/test_black.py::TestCaching::test_failed_formatting_does_not_get_cached tests/test_black.py::TestCaching::test_write_cache_write_fail tests/test_black.py::TestCaching::test_read_cache_line_lengths tests/test_black.py::TestFileCollection::test_include_exclude tests/test_black.py::TestFileCollection::test_gitignore_used_as_default tests/test_black.py::TestFileCollection::test_gitignore_used_on_multiple_sources tests/test_black.py::TestFileCollection::test_exclude_for_issue_1572 tests/test_black.py::TestFileCollection::test_gitignore_exclude tests/test_black.py::TestFileCollection::test_nested_gitignore tests/test_black.py::TestFileCollection::test_nested_gitignore_directly_in_source_directory tests/test_black.py::TestFileCollection::test_invalid_gitignore tests/test_black.py::TestFileCollection::test_invalid_nested_gitignore tests/test_black.py::TestFileCollection::test_gitignore_that_ignores_subfolders tests/test_black.py::TestFileCollection::test_empty_include tests/test_black.py::TestFileCollection::test_include_absolute_path tests/test_black.py::TestFileCollection::test_exclude_absolute_path tests/test_black.py::TestFileCollection::test_extend_exclude tests/test_black.py::TestFileCollection::test_symlinks tests/test_black.py::TestFileCollection::test_get_sources_with_stdin tests/test_black.py::TestFileCollection::test_get_sources_with_stdin_filename tests/test_black.py::TestFileCollection::test_get_sources_with_stdin_filename_and_exclude tests/test_black.py::TestFileCollection::test_get_sources_with_stdin_filename_and_extend_exclude tests/test_black.py::TestFileCollection::test_get_sources_with_stdin_filename_and_force_exclude tests/test_black.py::TestFileCollection::test_get_sources_with_stdin_filename_and_force_exclude_and_symlink tests/test_black.py::TestDeFactoAPI::test_format_str tests/test_black.py::TestDeFactoAPI::test_format_file_contents