Repository: pallets/click

Issue:
8.0.2 doesn't validate paths correctly
Path arguments with a directory fail validation with "file not exists", despite existing, when resolve_path=True is specified

It looks like the code here concats the passed-in-path to its resolved directory, which means if the path contains a directory, the directory part gets duplicated

https://github.com/pallets/click/blob/0a81393fdf41edb0ab9d2f527eccdc8ce38d7d42/src/click/types.py#L852

e.g.

```$ mkdir relative-dir
$ touch relative-dir/myfile
$ python3
Python 3.9.7 (default, Sep 28 2021, 18:41:28)
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from click import Path
>>> p = Path(dir_okay=False, exists=True, writable=False, resolve_path=True)
>>> p.convert("relative-dir/myfile", None, None)
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/click/types.py", line 855, in convert
    st = os.stat(rv)
FileNotFoundError: [Errno 2] No such file or directory: '/relative-dir/relative-dir/myfile'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/site-packages/click/types.py", line 859, in convert
    self.fail(
  File "/usr/local/lib/python3.9/site-packages/click/types.py", line 128, in fail
    raise BadParameter(message, ctx=ctx, param=param)
click.exceptions.BadParameter: File 'relative-dir/myfile' does not exist.
```




Environment:

- Python version: 3.9.7
- Click version: 8.0.2



Discussion:
Seems that the fix for resolving across symlinks caused this. See #1813 (original discussion), #1825 (reverted), #1921 (next discussion), #2006 (currently applied), #2046.
Now that I look at the current code again, `os.readlink` really isn't a replacement for `os.path.realpath` the way it's used there, it's only being applied to one directory, not the entire path. Another suggestion was to use `pathlib.Path(rv).resolve()` instead, which has a different implementation than `realpath` which behaves the same on all supported Python versions. Pathlib has some overhead, but I think it's the best solution until we only support Python >= 3.8.

Resolving patch:
diff --git a/src/click/types.py b/src/click/types.py
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -836,20 +836,11 @@ def convert(
 
         if not is_dash:
             if self.resolve_path:
-                # Get the absolute directory containing the path.
-                dir_ = os.path.dirname(os.path.abspath(rv))
-
-                # Resolve a symlink. realpath on Windows Python < 3.9
-                # doesn't resolve symlinks. This might return a relative
-                # path even if the path to the link is absolute.
-                if os.path.islink(rv):
-                    rv = os.readlink(rv)
-
-                # Join dir_ with the resolved symlink if the resolved
-                # path is relative. This will make it relative to the
-                # original containing directory.
-                if not os.path.isabs(rv):
-                    rv = os.path.join(dir_, rv)
+                # os.path.realpath doesn't resolve symlinks on Windows
+                # until Python 3.8. Use pathlib for now.
+                import pathlib
+
+                rv = os.fsdecode(pathlib.Path(rv).resolve())
 
             try:
                 st = os.stat(rv)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,4 @@
 import os
-import shutil
 import tempfile
 
 import pytest
@@ -12,20 +11,17 @@ def runner(request):
     return CliRunner()
 
 
-def check_symlink_impl():
-    """This function checks if using symlinks is allowed
-    on the host machine"""
-    tempdir = tempfile.mkdtemp(prefix="click-")
-    test_pth = os.path.join(tempdir, "check_sym_impl")
-    sym_pth = os.path.join(tempdir, "link")
-    open(test_pth, "w").close()
-    rv = True
-    try:
-        os.symlink(test_pth, sym_pth)
-    except (NotImplementedError, OSError):
-        # Creating symlinks on Windows require elevated access.
-        # OSError is thrown if the function is called without it.
-        rv = False
-    finally:
-        shutil.rmtree(tempdir, ignore_errors=True)
-    return rv
+def _check_symlinks_supported():
+    with tempfile.TemporaryDirectory(prefix="click-pytest-") as tempdir:
+        target = os.path.join(tempdir, "target")
+        open(target, "w").close()
+        link = os.path.join(tempdir, "link")
+
+        try:
+            os.symlink(target, link)
+            return True
+        except OSError:
+            return False
+
+
+symlinks_supported = _check_symlinks_supported()
diff --git a/tests/test_types.py b/tests/test_types.py
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -2,7 +2,7 @@
 import pathlib
 
 import pytest
-from conftest import check_symlink_impl
+from conftest import symlinks_supported
 
 import click
 
@@ -104,37 +104,27 @@ def test_path_type(runner, cls, expect):
     assert result.return_value == expect
 
 
-@pytest.mark.skipif(not check_symlink_impl(), reason="symlink not allowed on device")
-@pytest.mark.parametrize(
-    ("sym_file", "abs_fun"),
-    [
-        (("relative_symlink",), os.path.basename),
-        (("test", "absolute_symlink"), lambda x: x),
-    ],
+@pytest.mark.skipif(
+    not symlinks_supported, reason="The current OS or FS doesn't support symlinks."
 )
-def test_symlink_resolution(tmpdir, sym_file, abs_fun):
-    """This test ensures symlinks are properly resolved by click"""
-    tempdir = str(tmpdir)
-    real_path = os.path.join(tempdir, "test_file")
-    sym_path = os.path.join(tempdir, *sym_file)
-
-    # create dirs and files
-    os.makedirs(os.path.join(tempdir, "test"), exist_ok=True)
-    open(real_path, "w").close()
-    os.symlink(abs_fun(real_path), sym_path)
-
-    # test
-    ctx = click.Context(click.Command("do_stuff"))
-    rv = click.Path(resolve_path=True).convert(sym_path, None, ctx)
-
-    # os.readlink prepends path prefixes to absolute
-    # links in windows.
-    # https://docs.microsoft.com/en-us/windows/win32/
-    # ... fileio/naming-a-file#win32-file-namespaces
-    #
-    # Here we strip win32 path prefix from the resolved path
-    rv_drive, rv_path = os.path.splitdrive(rv)
-    stripped_rv_drive = rv_drive.split(os.path.sep)[-1]
-    rv = os.path.join(stripped_rv_drive, rv_path)
-
-    assert rv == real_path
+def test_path_resolve_symlink(tmp_path, runner):
+    test_file = tmp_path / "file"
+    test_file_str = os.fsdecode(test_file)
+    test_file.write_text("")
+
+    path_type = click.Path(resolve_path=True)
+    param = click.Argument(["a"], type=path_type)
+    ctx = click.Context(click.Command("cli", params=[param]))
+
+    test_dir = tmp_path / "dir"
+    test_dir.mkdir()
+
+    abs_link = test_dir / "abs"
+    abs_link.symlink_to(test_file)
+    abs_rv = path_type.convert(os.fsdecode(abs_link), param, ctx)
+    assert abs_rv == test_file_str
+
+    rel_link = test_dir / "rel"
+    rel_link.symlink_to(pathlib.Path("..") / "file")
+    rel_rv = path_type.convert(os.fsdecode(rel_link), param, ctx)
+    assert rel_rv == test_file_str