Repository: borgbackup/borg

Issue:
Invalid location format on cifs/smb
I got a samba share mounted on _/run/user/1000/gvfs/smb-share:server=diskstation.local,share=backup_.

When initializing a repo using `borg init /run/user/1000/gvfs/smb-share:server=diskstation.local,share=backup` I get:
_borg init: error: argument REPOSITORY: Invalid location format: "/run/user/1000/gvfs/smb-share:server=diskstation.local,share=backup"_



Discussion:
A single double-dot should be allowed in paths, but it looks like it isn't. Probably an oversight / simplification of the parsing regex here.

```
(?P<proto>file)://(?P<path>[^:]+)(?:::(?P<archive>[^/]+))?$
                             ^
```

In the meantime, try a bind-mount to have a path point at your real mount point, which doesn't contain any double-dots:

```
# mount --bind /run/user/1000/gvfs/smb-share:server=diskstation.local,share=backup /mnt/theonebackup
```

A symlink doesn't seem to work. (Another thing to fix here?)

https://regex101.com/

```
(?P<path>.+?(?=(?:::|$)))(?:::(?P<archive>[^/]+))?$

some:path::archive
```


Resolving patch:
diff --git a/borg/helpers.py b/borg/helpers.py
--- a/borg/helpers.py
+++ b/borg/helpers.py
@@ -768,20 +768,49 @@ class Location:
     """Object representing a repository / archive location
     """
     proto = user = host = port = path = archive = None
+
+    # path must not contain :: (it ends at :: or string end), but may contain single colons.
+    # to avoid ambiguities with other regexes, it must also not start with ":".
+    path_re = r"""
+        (?!:)                                               # not starting with ":"
+        (?P<path>([^:]|(:(?!:)))+)                          # any chars, but no "::"
+        """
+    # optional ::archive_name at the end, archive name must not contain "/".
     # borg mount's FUSE filesystem creates one level of directories from
-    # the archive names. Thus, we must not accept "/" in archive names.
-    ssh_re = re.compile(r'(?P<proto>ssh)://(?:(?P<user>[^@]+)@)?'
-                        r'(?P<host>[^:/#]+)(?::(?P<port>\d+))?'
-                        r'(?P<path>[^:]+)(?:::(?P<archive>[^/]+))?$')
-    file_re = re.compile(r'(?P<proto>file)://'
-                         r'(?P<path>[^:]+)(?:::(?P<archive>[^/]+))?$')
-    scp_re = re.compile(r'((?:(?P<user>[^@]+)@)?(?P<host>[^:/]+):)?'
-                        r'(?P<path>[^:]+)(?:::(?P<archive>[^/]+))?$')
-    # get the repo from BORG_RE env and the optional archive from param.
+    # the archive names and of course "/" is not valid in a directory name.
+    optional_archive_re = r"""
+        (?:
+            ::                                              # "::" as separator
+            (?P<archive>[^/]+)                              # archive name must not contain "/"
+        )?$"""                                              # must match until the end
+
+    # regexes for misc. kinds of supported location specifiers:
+    ssh_re = re.compile(r"""
+        (?P<proto>ssh)://                                   # ssh://
+        (?:(?P<user>[^@]+)@)?                               # user@  (optional)
+        (?P<host>[^:/]+)(?::(?P<port>\d+))?                 # host or host:port
+        """ + path_re + optional_archive_re, re.VERBOSE)    # path or path::archive
+
+    file_re = re.compile(r"""
+        (?P<proto>file)://                                  # file://
+        """ + path_re + optional_archive_re, re.VERBOSE)    # path or path::archive
+
+    # note: scp_re is also use for local pathes
+    scp_re = re.compile(r"""
+        (
+            (?:(?P<user>[^@]+)@)?                           # user@  (optional)
+            (?P<host>[^:/]+):                               # host: (don't match / in host to disambiguate from file:)
+        )?                                                  # user@host: part is optional
+        """ + path_re + optional_archive_re, re.VERBOSE)    # path with optional archive
+
+    # get the repo from BORG_REPO env and the optional archive from param.
     # if the syntax requires giving REPOSITORY (see "borg mount"),
     # use "::" to let it use the env var.
     # if REPOSITORY argument is optional, it'll automatically use the env.
-    env_re = re.compile(r'(?:::(?P<archive>[^/]+)?)?$')
+    env_re = re.compile(r"""                                # the repo part is fetched from BORG_REPO
+        (?:::$)                                             # just "::" is ok (when a pos. arg is required, no archive)
+        |                                                   # or
+        """ + optional_archive_re, re.VERBOSE)              # archive name (optional, may be empty)
 
     def __init__(self, text=''):
         self.orig = text
diff --git a/borg/testsuite/helpers.py b/borg/testsuite/helpers.py
--- a/borg/testsuite/helpers.py
+++ b/borg/testsuite/helpers.py
@@ -39,6 +39,8 @@ def test_ssh(self, monkeypatch):
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive='archive')"
         assert repr(Location('ssh://user@host:1234/some/path')) == \
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive=None)"
+        assert repr(Location('ssh://user@host/some/path')) == \
+            "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive=None)"
 
     def test_file(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
@@ -75,6 +77,15 @@ def test_relpath(self, monkeypatch):
         assert repr(Location('some/relative/path')) == \
             "Location(proto='file', user=None, host=None, port=None, path='some/relative/path', archive=None)"
 
+    def test_with_colons(self, monkeypatch):
+        monkeypatch.delenv('BORG_REPO', raising=False)
+        assert repr(Location('/abs/path:w:cols::arch:col')) == \
+            "Location(proto='file', user=None, host=None, port=None, path='/abs/path:w:cols', archive='arch:col')"
+        assert repr(Location('/abs/path:with:colons::archive')) == \
+            "Location(proto='file', user=None, host=None, port=None, path='/abs/path:with:colons', archive='archive')"
+        assert repr(Location('/abs/path:with:colons')) == \
+            "Location(proto='file', user=None, host=None, port=None, path='/abs/path:with:colons', archive=None)"
+
     def test_underspecified(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
         with pytest.raises(ValueError):
@@ -84,11 +95,6 @@ def test_underspecified(self, monkeypatch):
         with pytest.raises(ValueError):
             Location()
 
-    def test_no_double_colon(self, monkeypatch):
-        monkeypatch.delenv('BORG_REPO', raising=False)
-        with pytest.raises(ValueError):
-            Location('ssh://localhost:22/path:archive')
-
     def test_no_slashes(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
         with pytest.raises(ValueError):
@@ -119,43 +125,64 @@ def test_ssh(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'ssh://user@host:1234/some/path')
         assert repr(Location('::archive')) == \
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive='archive')"
-        assert repr(Location()) == \
+        assert repr(Location('::')) == \
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive=None)"
+        assert repr(Location()) == \
+               "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive=None)"
 
     def test_file(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'file:///some/path')
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='/some/path', archive='archive')"
-        assert repr(Location()) == \
+        assert repr(Location('::')) == \
             "Location(proto='file', user=None, host=None, port=None, path='/some/path', archive=None)"
+        assert repr(Location()) == \
+               "Location(proto='file', user=None, host=None, port=None, path='/some/path', archive=None)"
 
     def test_scp(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'user@host:/some/path')
         assert repr(Location('::archive')) == \
             "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive='archive')"
-        assert repr(Location()) == \
+        assert repr(Location('::')) == \
             "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive=None)"
+        assert repr(Location()) == \
+               "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive=None)"
 
     def test_folder(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'path')
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='path', archive='archive')"
-        assert repr(Location()) == \
+        assert repr(Location('::')) == \
             "Location(proto='file', user=None, host=None, port=None, path='path', archive=None)"
+        assert repr(Location()) == \
+               "Location(proto='file', user=None, host=None, port=None, path='path', archive=None)"
 
     def test_abspath(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', '/some/absolute/path')
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='/some/absolute/path', archive='archive')"
-        assert repr(Location()) == \
+        assert repr(Location('::')) == \
             "Location(proto='file', user=None, host=None, port=None, path='/some/absolute/path', archive=None)"
+        assert repr(Location()) == \
+               "Location(proto='file', user=None, host=None, port=None, path='/some/absolute/path', archive=None)"
 
     def test_relpath(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'some/relative/path')
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='some/relative/path', archive='archive')"
-        assert repr(Location()) == \
+        assert repr(Location('::')) == \
             "Location(proto='file', user=None, host=None, port=None, path='some/relative/path', archive=None)"
+        assert repr(Location()) == \
+               "Location(proto='file', user=None, host=None, port=None, path='some/relative/path', archive=None)"
+
+    def test_with_colons(self, monkeypatch):
+        monkeypatch.setenv('BORG_REPO', '/abs/path:w:cols')
+        assert repr(Location('::arch:col')) == \
+            "Location(proto='file', user=None, host=None, port=None, path='/abs/path:w:cols', archive='arch:col')"
+        assert repr(Location('::')) == \
+               "Location(proto='file', user=None, host=None, port=None, path='/abs/path:w:cols', archive=None)"
+        assert repr(Location()) == \
+               "Location(proto='file', user=None, host=None, port=None, path='/abs/path:w:cols', archive=None)"
 
     def test_no_slashes(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', '/some/absolute/path')


Test patch: