Repository: borgbackup/borg

Issue:
correctly normalize archived paths (windows)
borg2 / master branch:

`test_archived_paths` is currently skipped on win32 because it fails.

- there are unwanted drive letters in archived paths (`D:`)
- there are unwanted absolute paths in archived paths (`/a/...`)

```
____________________ ArchiverTestCase.test_archived_paths _____________________
[gw0] win32 -- Python 3.10.12 D:/a/_temp/msys64/ucrt64/bin/python.exe

self = <borg.testsuite.archiver.create_cmd.ArchiverTestCase testMethod=test_archived_paths>

    def test_archived_paths(self):
        # As borg comes from the POSIX (Linux, UNIX) world, a lot of stuff assumes path separators
        # to be slashes "/", e.g.: in archived items, for pattern matching.
        # To make our lives easier and to support cross-platform extraction we always use slashes.
        # Similarly, archived paths are expected to be full, but relative (have no leading slash).
        full_path = os.path.abspath(os.path.join(self.input_path, "test"))
        # remove windows drive letter, if any:
        posix_path = full_path[2:] if full_path[1] == ":" else full_path
        # only needed on windows in case there are backslashes:
        posix_path = posix_path.replace("\\", "/")
        # no leading slash in borg archives:
        archived_path = posix_path.lstrip("/")
        self.create_regular_file("test")
        self.cmd(f"--repo={self.repository_location}", "rcreate", "--encryption=none")
        self.cmd(f"--repo={self.repository_location}", "create", "test", "input", full_path)
        # "input" directory is recursed into, "input/test" is discovered and joined by borg's recursion.
        # full_path was directly given as a cli argument and should end up as archive_path in the borg archive.
        expected_paths = sorted(["input", "input/test", archived_path])
        # check path in archived items:
        archive_list = self.cmd(f"--repo={self.repository_location}", "list", "test", "--short")
>       assert expected_paths == sorted([path for path in archive_list.splitlines() if path])
E       AssertionError: assert ['a/_temp/msys64/tmp/tmp72a38lrc/input/test', 'input', 'input/test'] == ['D:/a/_temp/msys64/tmp/tmp72a38lrc/input/test', 'input', 'input/test']
E         At index 0 diff: 'a/_temp/msys64/tmp/tmp72a38lrc/input/test' != 'D:/a/_temp/msys64/tmp/tmp72a38lrc/input/test'
E         Full diff:
E         - ['D:/a/_temp/msys64/tmp/tmp72a38lrc/input/test', 'input', 'input/test']
E         ?   ---
E         + ['a/_temp/msys64/tmp/tmp72a38lrc/input/test', 'input', 'input/test']
```



Discussion:
@RayyanAnsari can you check / think about what to do best?

On Linux/BSD/macOS borg archives paths like:
- `/etc/fstab` -> `etc/fstab`
- `./.ssh/known_hosts` -> `.ssh/known_hosts`
- `../foo/bar` -> `foo/bar` **IIRC**

This is done because at extraction time, we always want to extract **into** the current directory, not:
- extract somewhere to `/...`
- extract somewhere to `../...`

Following the same logic for win32, it seems to be appropriate to:
- completely drop the drive letter (as we do not want to extract to another drive, just the cwd)
- normalize the path in the same way as on POSIX (after converting backslash to slash)
where does this path manipulation happen? is it in create_cmd?

> Following the same logic for win32, it seems to be appropriate to:
    - completely drop the drive letter (as we do not want to extract to another drive, just the cwd)
    - normalize the path in the same way as on POSIX (after converting backslash to slash)

so a path like
`C:\Users\abcd\Documents\file.txt`
would become
`Users/abcd/Documents/file.txt`
`remove_dotdot_prefixes` does it.

Yes, that looks right.

Resolving patch:
diff --git a/src/borg/helpers/fs.py b/src/borg/helpers/fs.py
--- a/src/borg/helpers/fs.py
+++ b/src/borg/helpers/fs.py
@@ -1,7 +1,7 @@
 import errno
 import hashlib
 import os
-import os.path
+import posixpath
 import re
 import stat
 import subprocess
@@ -231,7 +231,7 @@ def make_path_safe(path):
     path = path.lstrip("/")
     if path.startswith("../") or "/../" in path or path.endswith("/..") or path == "..":
         raise ValueError(f"unexpected '..' element in path {path!r}")
-    path = os.path.normpath(path)
+    path = posixpath.normpath(path)
     return path
 
 
@@ -245,6 +245,11 @@ def remove_dotdot_prefixes(path):
 
     `path` is expected to be normalized already (e.g. via `os.path.normpath()`).
     """
+    if is_win32:
+        if len(path) > 1 and path[1] == ":":
+            path = path.replace(":", "", 1)
+        path = path.replace("\\", "/")
+
     path = path.lstrip("/")
     path = _dotdot_re.sub("", path)
     if path in ["", ".."]:
diff --git a/src/borg/testsuite/archiver/create_cmd.py b/src/borg/testsuite/archiver/create_cmd.py
--- a/src/borg/testsuite/archiver/create_cmd.py
+++ b/src/borg/testsuite/archiver/create_cmd.py
@@ -103,15 +103,14 @@ def filter(output):
         # the interesting parts of info_output2 and info_output should be same
         self.assert_equal(filter(info_output), filter(info_output2))
 
-    @pytest.mark.skipif(is_win32, reason="still broken on windows")
     def test_archived_paths(self):
         # As borg comes from the POSIX (Linux, UNIX) world, a lot of stuff assumes path separators
         # to be slashes "/", e.g.: in archived items, for pattern matching.
         # To make our lives easier and to support cross-platform extraction we always use slashes.
         # Similarly, archived paths are expected to be full, but relative (have no leading slash).
         full_path = os.path.abspath(os.path.join(self.input_path, "test"))
-        # remove windows drive letter, if any:
-        posix_path = full_path[2:] if full_path[1] == ":" else full_path
+        # remove colon from windows drive letter, if any:
+        posix_path = full_path.replace(":", "") if full_path[1] == ":" else full_path
         # only needed on windows in case there are backslashes:
         posix_path = posix_path.replace("\\", "/")
         # no leading slash in borg archives:


Test patch: