Repository: psf/black

Issue:
error on windows directory junctions
**Describe the bug**

If a windows directory junction is found while crawling a source root, and it points outside of the source root, then black will error.  This happens even if the directory should be ignored!

**To Reproduce**

1. Start a windows cmd prompt
1. `mkdir source_root`
1. `cd source_root`
1. `mkdir .git`
1. `mklink /J dir_junction ..`
1. `python -m black --exclude dir_junction .`

Then you get this error:

```
Traceback (most recent call last):
  File "C:\python\lib\runpy.py", line 193, in _run_module_as_main
    "__main__", mod_spec)
  File "C:\python\lib\runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "C:\pythonlibs\black\__main__.py", line 3, in <module>
    patched_main()
  File "C:\pythonlibs\black\__init__.py", line 1283, in patched_main
    main()
  File "C:\pythonlibs\click\core.py", line 1128, in __call__
    return self.main(*args, **kwargs)
  File "C:\pythonlibs\click\core.py", line 1053, in main
    rv = self.invoke(ctx)
  File "C:\pythonlibs\click\core.py", line 1395, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "C:\pythonlibs\click\core.py", line 754, in invoke
    return __callback(*args, **kwargs)
  File "C:\pythonlibs\click\decorators.py", line 26, in new_func
    return f(get_current_context(), *args, **kwargs)
  File "C:\pythonlibs\black\__init__.py", line 443, in main
    stdin_filename=stdin_filename,
  File "C:\pythonlibs\black\__init__.py", line 549, in get_sources
    quiet=quiet,
  File "C:\pythonlibs\black\files.py", line 188, in gen_python_files
    normalized_path = normalize_path_maybe_ignore(child, root, report)
  File "C:\pythonlibs\black\files.py", line 142, in normalize_path_maybe_ignore
    normalized_path = abspath.resolve().relative_to(root).as_posix()
  File "C:\python\lib\pathlib.py", line 900, in relative_to
    .format(str(self), str(formatted)))
ValueError: 'C:\\Users\\TheUser' does not start with 'C:\\Users\\TheUser\\source_root'
```

**Expected behavior**

A directory we've explicitly told to ignore should be ignored!

**Environment (please complete the following information):**

- Version: 21.9b0
- OS and Python version: Windows / Python 3.7

**Does this bug also happen on main?**

Based on inspection of source, yes (see below)

**Additional context**

The error happens because in [this line](https://github.com/psf/black/blob/main/src/black/files.py#L142), it calls `.resolve().relative_to(root)`:

```
        normalized_path = abspath.resolve().relative_to(root).as_posix()
```

It seems windows directory junctions WILL have their linked path calculated by `resolve()` - however, this then triggers a ValueError, since it's not relative to `root` - this should be caught [here](https://github.com/psf/black/blob/467efe15562e3bad88b1eb3bc11f76b5b9a68816/src/black/files.py#L148):

```
        if path.is_symlink():
```

...except it seems that `is_symlink()` returns False for directory junctions! I wonder if a better check wouldn't be to just say that if, after the resolve, it's not relative to the root (regardless of whether we detect a symlink), then it's ignored? ie, something like:

```
    try:
        abspath = path if path.is_absolute() else Path.cwd() / path
        resolved_path = abspath.resolve()
        try:
            relative_path = resolved_path.relative_to(root)
        except ValueError:
            report.path_ignored(path, f"is a symbolic link that points outside {root}")
            return None
        normalized_path = relative_path.as_posix()
    except OSError as e:
        report.path_ignored(path, f"cannot be read because {e}")
        return None
```




Discussion:

Resolving patch:
diff --git a/src/black/files.py b/src/black/files.py
--- a/src/black/files.py
+++ b/src/black/files.py
@@ -151,23 +151,22 @@ def normalize_path_maybe_ignore(
     """
     try:
         abspath = path if path.is_absolute() else Path.cwd() / path
-        normalized_path = abspath.resolve().relative_to(root).as_posix()
-    except OSError as e:
-        if report:
-            report.path_ignored(path, f"cannot be read because {e}")
-        return None
-
-    except ValueError:
-        if path.is_symlink():
+        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
 
-        raise
+    except OSError as e:
+        if report:
+            report.path_ignored(path, f"cannot be read because {e}")
+        return None
 
-    return normalized_path
+    return root_relative_path
 
 
 def path_is_excluded(


Test patch:
diff --git a/tests/test_black.py b/tests/test_black.py
--- a/tests/test_black.py
+++ b/tests/test_black.py
@@ -1418,6 +1418,25 @@ def test_bpo_33660_workaround(self) -> None:
             normalized_path = black.normalize_path_maybe_ignore(path, root, report)
             self.assertEqual(normalized_path, "workspace/project")
 
+    def test_normalize_path_ignore_windows_junctions_outside_of_root(self) -> None:
+        if system() != "Windows":
+            return
+
+        with TemporaryDirectory() as workspace:
+            root = Path(workspace)
+            junction_dir = root / "junction"
+            junction_target_outside_of_root = root / ".."
+            os.system(f"mklink /J {junction_dir} {junction_target_outside_of_root}")
+
+            report = black.Report(verbose=True)
+            normalized_path = black.normalize_path_maybe_ignore(
+                junction_dir, root, report
+            )
+            # Manually delete for Python < 3.8
+            os.system(f"rmdir {junction_dir}")
+
+            self.assertEqual(normalized_path, None)
+
     def test_newline_comment_interaction(self) -> None:
         source = "class A:\\\r\n# type: ignore\n pass\n"
         output = black.format_str(source, mode=DEFAULT_MODE)
@@ -1994,7 +2013,6 @@ def test_symlink_out_of_root_directory(self) -> None:
         path.iterdir.return_value = [child]
         child.resolve.return_value = Path("/a/b/c")
         child.as_posix.return_value = "/a/b/c"
-        child.is_symlink.return_value = True
         try:
             list(
                 black.gen_python_files(
@@ -2014,31 +2032,6 @@ def test_symlink_out_of_root_directory(self) -> None:
             pytest.fail(f"`get_python_files_in_dir()` failed: {ve}")
         path.iterdir.assert_called_once()
         child.resolve.assert_called_once()
-        child.is_symlink.assert_called_once()
-        # `child` should behave like a strange file which resolved path is clearly
-        # outside of the `root` directory.
-        child.is_symlink.return_value = False
-        with pytest.raises(ValueError):
-            list(
-                black.gen_python_files(
-                    path.iterdir(),
-                    root,
-                    include,
-                    exclude,
-                    None,
-                    None,
-                    report,
-                    gitignore,
-                    verbose=False,
-                    quiet=False,
-                )
-            )
-        path.iterdir.assert_called()
-        assert path.iterdir.call_count == 2
-        child.resolve.assert_called()
-        assert child.resolve.call_count == 2
-        child.is_symlink.assert_called()
-        assert child.is_symlink.call_count == 2
 
     @patch("black.find_project_root", lambda *args: (THIS_DIR.resolve(), None))
     def test_get_sources_with_stdin(self) -> None: