Repository: iterative/dvc

Issue:
add: Path does not overlap with base path
## Description

Using `dvc add` on Windows can produce the error 

```
ERROR: Path: C:\Users\my_user\Desktop\DVC-2.4-TEST does not overlap with base path: C:\Users\my_user\Desktop\dvc-2.4-test
```

I believe this is because paths in Windows are case insensitive (note the difference in case between `DVC-2.4-TEST` and `dvc-2.4-test`)


### Reproduce

```
mkdir my_test
cd MY_TEST
git init
dvc init
```

Then, move data into the project folder.

```
dvc add mnist --verbose
```

Which outputs

```console
2021-06-23 08:22:44,410 DEBUG: Check for update is enabled.
2021-06-23 08:22:44,491 DEBUG: Trying to spawn '['daemon', '-q', 'updater']'
2021-06-23 08:22:44,536 DEBUG: Spawned '['daemon', '-q', 'updater']'
Adding...
2021-06-23 08:22:44,558 ERROR: Path: C:\Users\my_user\Desktop\MY_TEST does not overlap with base path: C:\Users\my_user\Desktop\my_test
------------------------------------------------------------
Traceback (most recent call last):
  File "c:\users\my_user\anaconda3\envs\dvc-2.4\lib\site-packages\dvc\command\add.py", line 21, in run
    self.repo.add(
  File "c:\users\my_user\anaconda3\envs\dvc-2.4\lib\site-packages\dvc\repo\__init__.py", line 51, in wrapper
    return f(repo, *args, **kwargs)
  File "c:\users\my_user\anaconda3\envs\dvc-2.4\lib\site-packages\dvc\repo\scm_context.py", line 14, in run
    return method(repo, *args, **kw)
  File "c:\users\my_user\anaconda3\envs\dvc-2.4\lib\site-packages\dvc\repo\add.py", line 92, in add
    stages = _create_stages(
  File "c:\users\my_user\anaconda3\envs\dvc-2.4\lib\site-packages\dvc\repo\add.py", line 250, in _create_stages
    path, wdir, out = resolve_paths(
  File "c:\users\my_user\anaconda3\envs\dvc-2.4\lib\site-packages\dvc\utils\__init__.py", line 386, in resolve_paths
    elif contains_symlink_up_to(dirname, repo.root_dir) or (
  File "c:\users\my_user\anaconda3\envs\dvc-2.4\lib\site-packages\dvc\utils\fs.py", line 81, in contains_symlink_up_to
    raise BasePathNotInCheckedPathException(path, base_path)
dvc.utils.fs.BasePathNotInCheckedPathException: Path: C:\Users\my_user\Desktop\MY_TEST does not overlap with base path: C:\Users\my_user\Desktop\my_test
------------------------------------------------------------
2021-06-23 08:22:44,563 DEBUG: Analytics is enabled.
2021-06-23 08:22:44,565 DEBUG: Trying to spawn '['daemon', '-q', 'analytics', 'C:\\Users\\my_user\\AppData\\Local\\Temp\\tmpgejvwwr6']'
2021-06-23 08:22:44,610 
```


### Expected

```
dvc add mnist
```

Which would output

```
100% Add|█████████████████████████████████████████████████████████████████████████████████████|1/1 [00:00,  1.76file/s]

To track the changes with git, run:

        git add mnist.dvc .gitignore
```

### Environment information

**Output of `dvc doctor`:**

```console
DVC version: 2.4.0+b7c6df
---------------------------------
Platform: Python 3.8.10 on Windows-10-10.0.19041-SP0
Supports: http, https
Cache types: <https://error.dvc.org/no-dvc-cache>
Caches: local
Remotes: local
Workspace directory: NTFS on C:\
Repo: dvc, git
```

**Additional Information (if any):**

This error does not happen if you do `cd my_test` instead of `cd MY_TEST`, or in general, if you specify the path with exactly the same case as the true file path.



Discussion:
in `dvc.utils.fs.contains_symlink_up_to()` we do a `if base_path in path` substring check without normalizing case for either path, which will fail on Windows in this case

Resolving patch:
diff --git a/dvc/utils/fs.py b/dvc/utils/fs.py
--- a/dvc/utils/fs.py
+++ b/dvc/utils/fs.py
@@ -4,11 +4,15 @@
 import shutil
 import stat
 import sys
+from typing import TYPE_CHECKING
 
 from dvc.exceptions import DvcException
 from dvc.system import System
 from dvc.utils import dict_md5
 
+if TYPE_CHECKING:
+    from dvc.types import StrPath
+
 logger = logging.getLogger(__name__)
 
 LOCAL_CHUNK_SIZE = 2 ** 20  # 1 MB
@@ -73,9 +77,9 @@ def __init__(self, path, base_path):
         super().__init__(msg)
 
 
-def contains_symlink_up_to(path, base_path):
-    base_path = os.fspath(base_path)
-    path = os.fspath(path)
+def contains_symlink_up_to(path: "StrPath", base_path: "StrPath"):
+    base_path = os.path.normcase(os.fspath(base_path))
+    path = os.path.normcase(os.fspath(path))
 
     if base_path not in path:
         raise BasePathNotInCheckedPathException(path, base_path)
@@ -144,11 +148,11 @@ def remove(path):
             raise
 
 
-def path_isin(child, parent):
+def path_isin(child: "StrPath", parent: "StrPath"):
     """Check if given `child` path is inside `parent`."""
 
-    def normalize_path(path):
-        return os.path.normpath(path)
+    def normalize_path(path) -> str:
+        return os.path.normcase(os.path.normpath(path))
 
     parent = os.path.join(normalize_path(parent), "")
     child = normalize_path(child)


Test patch:
diff --git a/tests/unit/utils/test_fs.py b/tests/unit/utils/test_fs.py
--- a/tests/unit/utils/test_fs.py
+++ b/tests/unit/utils/test_fs.py
@@ -216,6 +216,28 @@ def test_path_isin_with_absolute_path():
     assert path_isin(child, parent)
 
 
+def test_path_isin_case_sensitive():
+    child = os.path.join("path", "to", "folder")
+    parent = os.path.join("PATH", "TO")
+
+    assert path_isin(child, parent) == (os.name == "nt")
+
+
+@pytest.mark.skipif(os.name != "nt", reason="Windows specific")
+def test_contains_symlink_case_sensitive_win():
+    child = os.path.join("path", "to", "folder")
+    parent = os.path.join("PATH", "TO")
+    assert contains_symlink_up_to(child, parent) is False
+
+
+@pytest.mark.skipif(os.name == "nt", reason="Posix specific")
+def test_contains_symlink_case_sensitive_posix():
+    child = os.path.join("path", "to", "folder")
+    parent = os.path.join("PATH", "TO")
+    with pytest.raises(BasePathNotInCheckedPathException):
+        contains_symlink_up_to(child, parent)
+
+
 def test_makedirs(tmp_dir):
     path = os.path.join(tmp_dir, "directory")
     path_info = PathInfo(os.path.join(tmp_dir, "another", "directory"))