Bläddra i källkod

Merge pull request #1716 from ThomasWaldmann/merge-1.0-maint

Merge 1.0-maint
enkore 8 år sedan
förälder
incheckning
34bd7cb4e2

+ 3 - 4
Vagrantfile

@@ -114,7 +114,6 @@ def packages_openbsd
     chsh -s /usr/local/bin/bash vagrant
     chsh -s /usr/local/bin/bash vagrant
     pkg_add openssl
     pkg_add openssl
     pkg_add lz4
     pkg_add lz4
-    # pkg_add fuse  # does not install, sdl dependency missing
     pkg_add git  # no fakeroot
     pkg_add git  # no fakeroot
     pkg_add py3-setuptools
     pkg_add py3-setuptools
     ln -sf /usr/local/bin/python3.4 /usr/local/bin/python3
     ln -sf /usr/local/bin/python3.4 /usr/local/bin/python3
@@ -265,7 +264,7 @@ def build_binary_with_pyinstaller(boxname)
     cd /vagrant/borg
     cd /vagrant/borg
     . borg-env/bin/activate
     . borg-env/bin/activate
     cd borg
     cd borg
-    pyinstaller -F -n borg.exe --distpath=/vagrant/borg --clean src/borg/__main__.py --hidden-import=borg.platform.posix
+    pyinstaller --clean --distpath=/vagrant/borg scripts/borg.exe.spec
   EOF
   EOF
 end
 end
 
 
@@ -388,7 +387,7 @@ Vagrant.configure(2) do |config|
   end
   end
 
 
   config.vm.define "wheezy32" do |b|
   config.vm.define "wheezy32" do |b|
-    b.vm.box = "boxcutter/debian711-i386"
+    b.vm.box = "boxcutter/debian7-i386"
     b.vm.provision "packages prepare wheezy", :type => :shell, :inline => packages_prepare_wheezy
     b.vm.provision "packages prepare wheezy", :type => :shell, :inline => packages_prepare_wheezy
     b.vm.provision "packages debianoid", :type => :shell, :inline => packages_debianoid
     b.vm.provision "packages debianoid", :type => :shell, :inline => packages_debianoid
     b.vm.provision "install pyenv", :type => :shell, :privileged => false, :inline => install_pyenv("wheezy32")
     b.vm.provision "install pyenv", :type => :shell, :privileged => false, :inline => install_pyenv("wheezy32")
@@ -401,7 +400,7 @@ Vagrant.configure(2) do |config|
   end
   end
 
 
   config.vm.define "wheezy64" do |b|
   config.vm.define "wheezy64" do |b|
-    b.vm.box = "boxcutter/debian711"
+    b.vm.box = "boxcutter/debian7"
     b.vm.provision "packages prepare wheezy", :type => :shell, :inline => packages_prepare_wheezy
     b.vm.provision "packages prepare wheezy", :type => :shell, :inline => packages_prepare_wheezy
     b.vm.provision "packages debianoid", :type => :shell, :inline => packages_debianoid
     b.vm.provision "packages debianoid", :type => :shell, :inline => packages_debianoid
     b.vm.provision "install pyenv", :type => :shell, :privileged => false, :inline => install_pyenv("wheezy64")
     b.vm.provision "install pyenv", :type => :shell, :privileged => false, :inline => install_pyenv("wheezy64")

+ 6 - 0
docs/deployment.rst

@@ -54,6 +54,12 @@ Restrictions
 Borg is instructed to restrict clients into their own paths:
 Borg is instructed to restrict clients into their own paths:
 ``borg serve --restrict-to-path /home/backup/repos/<client fqdn>``
 ``borg serve --restrict-to-path /home/backup/repos/<client fqdn>``
 
 
+The client will be able to access any file or subdirectory inside of ``/home/backup/repos/<client fqdn>``
+but no other directories. You can allow a client to access several separate directories by passing multiple
+`--restrict-to-path` flags, for instance: ``borg serve --restrict-to-path /home/backup/repos/<client fqdn> --restrict-to-path /home/backup/repos/<other client fqdn>``,
+which could make sense if multiple machines belong to one person which should then have access to all the
+backups of their machines.
+
 There is only one ssh key per client allowed. Keys are added for ``johndoe.clnt.local``, ``web01.srv.local`` and
 There is only one ssh key per client allowed. Keys are added for ``johndoe.clnt.local``, ``web01.srv.local`` and
 ``app01.srv.local``. But they will access the backup under only one UNIX user account as:
 ``app01.srv.local``. But they will access the backup under only one UNIX user account as:
 ``backup@backup01.srv.local``. Every key in ``$HOME/.ssh/authorized_keys`` has a
 ``backup@backup01.srv.local``. Every key in ``$HOME/.ssh/authorized_keys`` has a

+ 33 - 0
docs/faq.rst

@@ -12,6 +12,39 @@ Yes, the `deduplication`_ technique used by
 |project_name| makes sure only the modified parts of the file are stored.
 |project_name| makes sure only the modified parts of the file are stored.
 Also, we have optional simple sparse file support for extract.
 Also, we have optional simple sparse file support for extract.
 
 
+If you use non-snapshotting backup tools like Borg to back up virtual machines,
+then these should be turned off for doing so. Backing up live VMs this way can (and will)
+result in corrupted or inconsistent backup contents: a VM image is just a regular file to
+Borg with the same issues as regular files when it comes to concurrent reading and writing from
+the same file.
+
+For backing up live VMs use file system snapshots on the VM host, which establishes
+crash-consistency for the VM images. This means that with most file systems
+(that are journaling) the FS will always be fine in the backup (but may need a
+journal replay to become accessible).
+
+Usually this does not mean that file *contents* on the VM are consistent, since file
+contents are normally not journaled. Notable exceptions are ext4 in data=journal mode,
+ZFS and btrfs (unless nodatacow is used).
+
+Applications designed with crash-consistency in mind (most relational databases
+like PostgreSQL, SQLite etc. but also for example Borg repositories) should always
+be able to recover to a consistent state from a backup created with
+crash-consistent snapshots (even on ext4 with data=writeback or XFS).
+
+Hypervisor snapshots capturing most of the VM's state can also be used for backups
+and can be a better alternative to pure file system based snapshots of the VM's disk,
+since no state is lost. Depending on the application this can be the easiest and most
+reliable way to create application-consistent backups.
+
+Other applications may require a lot of work to reach application-consistency:
+It's a broad and complex issue that cannot be explained in entirety here.
+
+Borg doesn't intend to address these issues due to their huge complexity
+and platform/software dependency. Combining Borg with the mechanisms provided
+by the platform (snapshots, hypervisor features) will be the best approach
+to start tackling them.
+
 Can I backup from multiple servers into a single repository?
 Can I backup from multiple servers into a single repository?
 ------------------------------------------------------------
 ------------------------------------------------------------
 
 

+ 38 - 0
scripts/borg.exe.spec

@@ -0,0 +1,38 @@
+# -*- mode: python -*-
+# this pyinstaller spec file is used to build borg binaries on posix platforms
+
+import os, sys
+
+basepath = '/vagrant/borg/borg'
+
+block_cipher = None
+
+a = Analysis([os.path.join(basepath, 'src/borg/__main__.py'), ],
+             pathex=[basepath, ],
+             binaries=[],
+             datas=[],
+             hiddenimports=['borg.platform.posix'],
+             hookspath=[],
+             runtime_hooks=[],
+             excludes=[],
+             win_no_prefer_redirects=False,
+             win_private_assemblies=False,
+             cipher=block_cipher)
+
+if sys.platform == 'darwin':
+    # do not bundle the osxfuse libraries, so we do not get a version
+    # mismatch to the installed kernel driver of osxfuse.
+    a.binaries = [b for b in a.binaries if 'libosxfuse' not in b[0]]
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(pyz,
+          a.scripts,
+          a.binaries,
+          a.zipfiles,
+          a.datas,
+          name='borg.exe',
+          debug=False,
+          strip=False,
+          upx=True,
+          console=True )

+ 3 - 1
src/borg/archiver.py

@@ -1528,7 +1528,9 @@ class Archiver:
                                           help='start repository server process')
                                           help='start repository server process')
         subparser.set_defaults(func=self.do_serve)
         subparser.set_defaults(func=self.do_serve)
         subparser.add_argument('--restrict-to-path', dest='restrict_to_paths', action='append',
         subparser.add_argument('--restrict-to-path', dest='restrict_to_paths', action='append',
-                               metavar='PATH', help='restrict repository access to PATH')
+                               metavar='PATH', help='restrict repository access to PATH. '
+                                                    'Can be specified multiple times to allow the client access to several directories. '
+                                                    'Access to all sub-directories is granted implicitly; PATH doesn\'t need to directly point to a repository.')
         subparser.add_argument('--append-only', dest='append_only', action='store_true',
         subparser.add_argument('--append-only', dest='append_only', action='store_true',
                                help='only allow appending to repository segment files')
                                help='only allow appending to repository segment files')
         init_epilog = textwrap.dedent("""
         init_epilog = textwrap.dedent("""

+ 11 - 2
src/borg/cache.py

@@ -29,8 +29,11 @@ FileCacheEntry = namedtuple('FileCacheEntry', 'age inode size mtime chunk_ids')
 class Cache:
 class Cache:
     """Client Side cache
     """Client Side cache
     """
     """
+    class RepositoryIDNotUnique(Error):
+        """Cache is newer than repository - do you have multiple, independently updated repos with same ID?"""
+
     class RepositoryReplay(Error):
     class RepositoryReplay(Error):
-        """Cache is newer than repository, refusing to continue"""
+        """Cache is newer than repository - this is either an attack or unsafe (multiple repos with same ID)"""
 
 
     class CacheInitAbortedError(Error):
     class CacheInitAbortedError(Error):
         """Cache initialization aborted"""
         """Cache initialization aborted"""
@@ -92,11 +95,17 @@ class Cache:
                 if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.",
                 if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.",
                            retry=False, env_var_override='BORG_RELOCATED_REPO_ACCESS_IS_OK'):
                            retry=False, env_var_override='BORG_RELOCATED_REPO_ACCESS_IS_OK'):
                     raise self.RepositoryAccessAborted()
                     raise self.RepositoryAccessAborted()
+                # adapt on-disk config immediately if the new location was accepted
+                self.begin_txn()
+                self.commit()
 
 
             if sync and self.manifest.id != self.manifest_id:
             if sync and self.manifest.id != self.manifest_id:
                 # If repository is older than the cache something fishy is going on
                 # If repository is older than the cache something fishy is going on
                 if self.timestamp and self.timestamp > manifest.timestamp:
                 if self.timestamp and self.timestamp > manifest.timestamp:
-                    raise self.RepositoryReplay()
+                    if isinstance(key, PlaintextKey):
+                        raise self.RepositoryIDNotUnique()
+                    else:
+                        raise self.RepositoryReplay()
                 # Make sure an encrypted repository has not been swapped for an unencrypted repository
                 # Make sure an encrypted repository has not been swapped for an unencrypted repository
                 if self.key_type is not None and self.key_type != str(key.TYPE):
                 if self.key_type is not None and self.key_type != str(key.TYPE):
                     raise self.EncryptionMethodMismatch()
                     raise self.EncryptionMethodMismatch()

+ 40 - 11
src/borg/helpers.py

@@ -68,7 +68,7 @@ class ErrorWithTraceback(Error):
 
 
 
 
 class IntegrityError(ErrorWithTraceback):
 class IntegrityError(ErrorWithTraceback):
-    """Data integrity error"""
+    """Data integrity error: {}"""
 
 
 
 
 class ExtensionModuleError(Error):
 class ExtensionModuleError(Error):
@@ -866,20 +866,49 @@ class Location:
     """Object representing a repository / archive location
     """Object representing a repository / archive location
     """
     """
     proto = user = host = port = path = archive = None
     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
     # 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"),
     # if the syntax requires giving REPOSITORY (see "borg mount"),
     # use "::" to let it use the env var.
     # use "::" to let it use the env var.
     # if REPOSITORY argument is optional, it'll automatically use the env.
     # 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=''):
     def __init__(self, text=''):
         self.orig = text
         self.orig = text

+ 5 - 5
src/borg/key.py

@@ -113,7 +113,7 @@ class KeyBase:
         if id:
         if id:
             id_computed = self.id_hash(data)
             id_computed = self.id_hash(data)
             if not compare_digest(id_computed, id):
             if not compare_digest(id_computed, id):
-                raise IntegrityError('Chunk id verification failed')
+                raise IntegrityError('Chunk %s: id verification failed' % bin_to_hex(id))
 
 
 
 
 class PlaintextKey(KeyBase):
 class PlaintextKey(KeyBase):
@@ -140,7 +140,7 @@ class PlaintextKey(KeyBase):
 
 
     def decrypt(self, id, data, decompress=True):
     def decrypt(self, id, data, decompress=True):
         if data[0] != self.TYPE:
         if data[0] != self.TYPE:
-            raise IntegrityError('Invalid encryption envelope')
+            raise IntegrityError('Chunk %s: Invalid encryption envelope' % bin_to_hex(id))
         payload = memoryview(data)[1:]
         payload = memoryview(data)[1:]
         if not decompress:
         if not decompress:
             return Chunk(payload)
             return Chunk(payload)
@@ -180,12 +180,12 @@ class AESKeyBase(KeyBase):
     def decrypt(self, id, data, decompress=True):
     def decrypt(self, id, data, decompress=True):
         if not (data[0] == self.TYPE or
         if not (data[0] == self.TYPE or
             data[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
             data[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
-            raise IntegrityError('Invalid encryption envelope')
+            raise IntegrityError('Chunk %s: Invalid encryption envelope' % bin_to_hex(id))
         data_view = memoryview(data)
         data_view = memoryview(data)
         hmac_given = data_view[1:33]
         hmac_given = data_view[1:33]
         hmac_computed = memoryview(hmac_sha256(self.enc_hmac_key, data_view[33:]))
         hmac_computed = memoryview(hmac_sha256(self.enc_hmac_key, data_view[33:]))
         if not compare_digest(hmac_computed, hmac_given):
         if not compare_digest(hmac_computed, hmac_given):
-            raise IntegrityError('Encryption envelope checksum mismatch')
+            raise IntegrityError('Chunk %s: Encryption envelope checksum mismatch' % bin_to_hex(id))
         self.dec_cipher.reset(iv=PREFIX + data[33:41])
         self.dec_cipher.reset(iv=PREFIX + data[33:41])
         payload = self.dec_cipher.decrypt(data_view[41:])
         payload = self.dec_cipher.decrypt(data_view[41:])
         if not decompress:
         if not decompress:
@@ -197,7 +197,7 @@ class AESKeyBase(KeyBase):
     def extract_nonce(self, payload):
     def extract_nonce(self, payload):
         if not (payload[0] == self.TYPE or
         if not (payload[0] == self.TYPE or
             payload[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
             payload[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
-            raise IntegrityError('Invalid encryption envelope')
+            raise IntegrityError('Manifest: Invalid encryption envelope')
         nonce = bytes_to_long(payload[33:41])
         nonce = bytes_to_long(payload[33:41])
         return nonce
         return nonce
 
 

+ 7 - 7
src/borg/keymanager.py

@@ -1,10 +1,10 @@
-from binascii import hexlify, unhexlify, a2b_base64, b2a_base64
+from binascii import unhexlify, a2b_base64, b2a_base64
 import binascii
 import binascii
 import textwrap
 import textwrap
 from hashlib import sha256
 from hashlib import sha256
 
 
 from .key import KeyfileKey, RepoKey, PassphraseKey, KeyfileNotFoundError, PlaintextKey
 from .key import KeyfileKey, RepoKey, PassphraseKey, KeyfileNotFoundError, PlaintextKey
-from .helpers import Manifest, NoManifestError, Error, yes
+from .helpers import Manifest, NoManifestError, Error, yes, bin_to_hex
 from .repository import Repository
 from .repository import Repository
 
 
 
 
@@ -79,7 +79,7 @@ class KeyManager:
 
 
     def store_keyfile(self, target):
     def store_keyfile(self, target):
         with open(target, 'w') as fd:
         with open(target, 'w') as fd:
-            fd.write('%s %s\n' % (KeyfileKey.FILE_ID, hexlify(self.repository.id).decode('ascii')))
+            fd.write('%s %s\n' % (KeyfileKey.FILE_ID, bin_to_hex(self.repository.id)))
             fd.write(self.keyblob)
             fd.write(self.keyblob)
             if not self.keyblob.endswith('\n'):
             if not self.keyblob.endswith('\n'):
                 fd.write('\n')
                 fd.write('\n')
@@ -103,7 +103,7 @@ class KeyManager:
         binary = a2b_base64(self.keyblob)
         binary = a2b_base64(self.keyblob)
         export += 'BORG PAPER KEY v1\n'
         export += 'BORG PAPER KEY v1\n'
         lines = (len(binary) + 17) // 18
         lines = (len(binary) + 17) // 18
-        repoid = hexlify(self.repository.id).decode('ascii')[:18]
+        repoid = bin_to_hex(self.repository.id)[:18]
         complete_checksum = sha256_truncated(binary, 12)
         complete_checksum = sha256_truncated(binary, 12)
         export += 'id: {0:d} / {1} / {2} - {3}\n'.format(lines,
         export += 'id: {0:d} / {1} / {2} - {3}\n'.format(lines,
                                        grouped(repoid),
                                        grouped(repoid),
@@ -114,7 +114,7 @@ class KeyManager:
             idx += 1
             idx += 1
             binline = binary[:18]
             binline = binary[:18]
             checksum = sha256_truncated(idx.to_bytes(2, byteorder='big') + binline, 2)
             checksum = sha256_truncated(idx.to_bytes(2, byteorder='big') + binline, 2)
-            export += '{0:2d}: {1} - {2}\n'.format(idx, grouped(hexlify(binline).decode('ascii')), checksum)
+            export += '{0:2d}: {1} - {2}\n'.format(idx, grouped(bin_to_hex(binline)), checksum)
             binary = binary[18:]
             binary = binary[18:]
 
 
         if path:
         if path:
@@ -125,7 +125,7 @@ class KeyManager:
 
 
     def import_keyfile(self, args):
     def import_keyfile(self, args):
         file_id = KeyfileKey.FILE_ID
         file_id = KeyfileKey.FILE_ID
-        first_line = file_id + ' ' + hexlify(self.repository.id).decode('ascii') + '\n'
+        first_line = file_id + ' ' + bin_to_hex(self.repository.id) + '\n'
         with open(args.path, 'r') as fd:
         with open(args.path, 'r') as fd:
             file_first_line = fd.read(len(first_line))
             file_first_line = fd.read(len(first_line))
             if file_first_line != first_line:
             if file_first_line != first_line:
@@ -141,7 +141,7 @@ class KeyManager:
         # imported here because it has global side effects
         # imported here because it has global side effects
         import readline
         import readline
 
 
-        repoid = hexlify(self.repository.id).decode('ascii')[:18]
+        repoid = bin_to_hex(self.repository.id)[:18]
         try:
         try:
             while True:  # used for repeating on overall checksum mismatch
             while True:  # used for repeating on overall checksum mismatch
                 # id line input
                 # id line input

+ 2 - 4
src/borg/testsuite/__init__.py

@@ -183,11 +183,9 @@ class BaseTestCase(unittest.TestCase):
             self._assert_dirs_equal_cmp(sub_diff)
             self._assert_dirs_equal_cmp(sub_diff)
 
 
     @contextmanager
     @contextmanager
-    def fuse_mount(self, location, mountpoint, mount_options=None):
+    def fuse_mount(self, location, mountpoint, *options):
         os.mkdir(mountpoint)
         os.mkdir(mountpoint)
-        args = ['mount', location, mountpoint]
-        if mount_options:
-            args += '-o', mount_options
+        args = ['mount', location, mountpoint] + list(options)
         self.cmd(*args, fork=True)
         self.cmd(*args, fork=True)
         self.wait_for_mount(mountpoint)
         self.wait_for_mount(mountpoint)
         yield
         yield

+ 5 - 5
src/borg/testsuite/archiver.py

@@ -1,4 +1,4 @@
-from binascii import hexlify, unhexlify, b2a_base64
+from binascii import unhexlify, b2a_base64
 from configparser import ConfigParser
 from configparser import ConfigParser
 import errno
 import errno
 import os
 import os
@@ -1473,7 +1473,7 @@ class ArchiverTestCase(ArchiverTestCaseBase):
         self.cmd('create', self.repository_location + '::archive2', 'input')
         self.cmd('create', self.repository_location + '::archive2', 'input')
         mountpoint = os.path.join(self.tmpdir, 'mountpoint')
         mountpoint = os.path.join(self.tmpdir, 'mountpoint')
         # mount the whole repository, archive contents shall show up in versioned view:
         # mount the whole repository, archive contents shall show up in versioned view:
-        with self.fuse_mount(self.repository_location, mountpoint, 'versions'):
+        with self.fuse_mount(self.repository_location, mountpoint, '-o', 'versions'):
             path = os.path.join(mountpoint, 'input', 'test')  # filename shows up as directory ...
             path = os.path.join(mountpoint, 'input', 'test')  # filename shows up as directory ...
             files = os.listdir(path)
             files = os.listdir(path)
             assert all(f.startswith('test.') for f in files)  # ... with files test.xxxxxxxx in there
             assert all(f.startswith('test.') for f in files)  # ... with files test.xxxxxxxx in there
@@ -1505,7 +1505,7 @@ class ArchiverTestCase(ArchiverTestCaseBase):
             with pytest.raises(OSError) as excinfo:
             with pytest.raises(OSError) as excinfo:
                 open(os.path.join(mountpoint, path))
                 open(os.path.join(mountpoint, path))
             assert excinfo.value.errno == errno.EIO
             assert excinfo.value.errno == errno.EIO
-        with self.fuse_mount(self.repository_location + '::archive', mountpoint, 'allow_damaged_files'):
+        with self.fuse_mount(self.repository_location + '::archive', mountpoint, '-o', 'allow_damaged_files'):
             open(os.path.join(mountpoint, path)).close()
             open(os.path.join(mountpoint, path)).close()
 
 
     def verify_aes_counter_uniqueness(self, method):
     def verify_aes_counter_uniqueness(self, method):
@@ -1835,7 +1835,7 @@ class ArchiverTestCase(ArchiverTestCaseBase):
         with open(export_file, 'r') as fd:
         with open(export_file, 'r') as fd:
             export_contents = fd.read()
             export_contents = fd.read()
 
 
-        assert export_contents.startswith('BORG_KEY ' + hexlify(repo_id).decode() + '\n')
+        assert export_contents.startswith('BORG_KEY ' + bin_to_hex(repo_id) + '\n')
 
 
         key_file = self.keys_path + '/' + os.listdir(self.keys_path)[0]
         key_file = self.keys_path + '/' + os.listdir(self.keys_path)[0]
 
 
@@ -1862,7 +1862,7 @@ class ArchiverTestCase(ArchiverTestCaseBase):
         with open(export_file, 'r') as fd:
         with open(export_file, 'r') as fd:
             export_contents = fd.read()
             export_contents = fd.read()
 
 
-        assert export_contents.startswith('BORG_KEY ' + hexlify(repo_id).decode() + '\n')
+        assert export_contents.startswith('BORG_KEY ' + bin_to_hex(repo_id) + '\n')
 
 
         with Repository(self.repository_path) as repository:
         with Repository(self.repository_path) as repository:
             repo_key = RepoKey(repository)
             repo_key = RepoKey(repository)

+ 38 - 11
src/borg/testsuite/helpers.py

@@ -54,6 +54,8 @@ class TestLocationWithoutEnv:
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive='archive')"
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive='archive')"
         assert repr(Location('ssh://user@host:1234/some/path')) == \
         assert repr(Location('ssh://user@host:1234/some/path')) == \
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive=None)"
             "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):
     def test_file(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
         monkeypatch.delenv('BORG_REPO', raising=False)
@@ -90,6 +92,15 @@ class TestLocationWithoutEnv:
         assert repr(Location('some/relative/path')) == \
         assert repr(Location('some/relative/path')) == \
             "Location(proto='file', user=None, host=None, port=None, path='some/relative/path', archive=None)"
             "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):
     def test_underspecified(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
         monkeypatch.delenv('BORG_REPO', raising=False)
         with pytest.raises(ValueError):
         with pytest.raises(ValueError):
@@ -99,11 +110,6 @@ class TestLocationWithoutEnv:
         with pytest.raises(ValueError):
         with pytest.raises(ValueError):
             Location()
             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):
     def test_no_slashes(self, monkeypatch):
         monkeypatch.delenv('BORG_REPO', raising=False)
         monkeypatch.delenv('BORG_REPO', raising=False)
         with pytest.raises(ValueError):
         with pytest.raises(ValueError):
@@ -134,43 +140,64 @@ class TestLocationWithEnv:
         monkeypatch.setenv('BORG_REPO', 'ssh://user@host:1234/some/path')
         monkeypatch.setenv('BORG_REPO', 'ssh://user@host:1234/some/path')
         assert repr(Location('::archive')) == \
         assert repr(Location('::archive')) == \
             "Location(proto='ssh', user='user', host='host', port=1234, path='/some/path', archive='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)"
             "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):
     def test_file(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'file:///some/path')
         monkeypatch.setenv('BORG_REPO', 'file:///some/path')
         assert repr(Location('::archive')) == \
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='/some/path', archive='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)"
             "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):
     def test_scp(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'user@host:/some/path')
         monkeypatch.setenv('BORG_REPO', 'user@host:/some/path')
         assert repr(Location('::archive')) == \
         assert repr(Location('::archive')) == \
             "Location(proto='ssh', user='user', host='host', port=None, path='/some/path', archive='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)"
             "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):
     def test_folder(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'path')
         monkeypatch.setenv('BORG_REPO', 'path')
         assert repr(Location('::archive')) == \
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='path', archive='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)"
             "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):
     def test_abspath(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', '/some/absolute/path')
         monkeypatch.setenv('BORG_REPO', '/some/absolute/path')
         assert repr(Location('::archive')) == \
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='/some/absolute/path', archive='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)"
             "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):
     def test_relpath(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', 'some/relative/path')
         monkeypatch.setenv('BORG_REPO', 'some/relative/path')
         assert repr(Location('::archive')) == \
         assert repr(Location('::archive')) == \
             "Location(proto='file', user=None, host=None, port=None, path='some/relative/path', archive='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)"
             "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):
     def test_no_slashes(self, monkeypatch):
         monkeypatch.setenv('BORG_REPO', '/some/absolute/path')
         monkeypatch.setenv('BORG_REPO', '/some/absolute/path')

+ 1 - 1
src/borg/testsuite/key.py

@@ -157,7 +157,7 @@ class TestKey:
         data = bytearray(data)
         data = bytearray(data)
         data[offset] += 1
         data[offset] += 1
         with pytest.raises(IntegrityError):
         with pytest.raises(IntegrityError):
-            key.decrypt("", data)
+            key.decrypt(b'', data)
 
 
     def test_decrypt_integrity(self, monkeypatch, keys_dir):
     def test_decrypt_integrity(self, monkeypatch, keys_dir):
         with keys_dir.join('keyfile').open('w') as fd:
         with keys_dir.join('keyfile').open('w') as fd: