diff --git a/borg/archiver.py b/borg/archiver.py index ff1463708..c40f8c091 100644 --- a/borg/archiver.py +++ b/borg/archiver.py @@ -1267,6 +1267,13 @@ class Archiver: browsing an archive or restoring individual files. Unless the ``--foreground`` option is given the command will run in the background until the filesystem is ``umounted``. + + For mount options, see the fuse(8) manual page. Additional mount options + supported by borg: + + - allow_damaged_files: by default damaged files (where missing chunks were + replaced with runs of zeros by borg check --repair) are not readable and + return EIO (I/O error). Set this option to read such files. """) subparser = subparsers.add_parser('mount', parents=[common_parser], description=self.do_mount.__doc__, diff --git a/borg/fuse.py b/borg/fuse.py index 19f8d71f2..b1387f200 100644 --- a/borg/fuse.py +++ b/borg/fuse.py @@ -6,11 +6,16 @@ import os import stat import tempfile import time -from .archive import Archive -from .helpers import daemonize, bigint_to_int from distutils.version import LooseVersion + import msgpack +from .archive import Archive +from .helpers import daemonize, bigint_to_int, remove_surrogates +from .logger import create_logger +logger = create_logger() + + # Does this version of llfuse support ns precision? have_fuse_xtime_ns = hasattr(llfuse.EntryAttributes, 'st_mtime_ns') @@ -42,6 +47,8 @@ class ItemCache: class FuseOperations(llfuse.Operations): """Export archive as a fuse filesystem """ + allow_damaged_files = True + def __init__(self, key, repository, manifest, archive, cached_repo): super().__init__() self._inode_count = 0 @@ -225,6 +232,15 @@ class FuseOperations(llfuse.Operations): return self.getattr(inode) def open(self, inode, flags, ctx=None): + if not self.allow_damaged_files: + item = self.get_item(inode) + if b'chunks_healthy' in item: + # Processed archive items don't carry the path anymore; for converting the inode + # to the path we'd either have to store the inverse of the current structure, + # or search the entire archive. So we just don't print it. It's easy to correlate anyway. + logger.warning('File has damaged (all-zero) chunks. Try running borg check --repair. ' + 'Mount with allow_damaged_files to read damaged files.') + raise llfuse.FUSEError(errno.EIO) return inode def opendir(self, inode, ctx=None): @@ -261,6 +277,11 @@ class FuseOperations(llfuse.Operations): options = ['fsname=borgfs', 'ro'] if extra_options: options.extend(extra_options.split(',')) + try: + options.remove('allow_damaged_files') + self.allow_damaged_files = True + except ValueError: + self.allow_damaged_files = False llfuse.init(self, mountpoint, options) if not foreground: daemonize() diff --git a/borg/testsuite/__init__.py b/borg/testsuite/__init__.py index b6f64b19c..64c240c2f 100644 --- a/borg/testsuite/__init__.py +++ b/borg/testsuite/__init__.py @@ -94,9 +94,12 @@ class BaseTestCase(unittest.TestCase): self._assert_dirs_equal_cmp(sub_diff) @contextmanager - def fuse_mount(self, location, mountpoint): + def fuse_mount(self, location, mountpoint, mount_options=None): os.mkdir(mountpoint) - self.cmd('mount', location, mountpoint, fork=True) + args = ['mount', location, mountpoint] + if mount_options: + args += '-o', mount_options + self.cmd(*args, fork=True) self.wait_for_mount(mountpoint) yield if sys.platform.startswith('linux'): diff --git a/borg/testsuite/archiver.py b/borg/testsuite/archiver.py index d52391239..6d08248fa 100644 --- a/borg/testsuite/archiver.py +++ b/borg/testsuite/archiver.py @@ -234,6 +234,13 @@ class ArchiverTestCaseBase(BaseTestCase): def create_src_archive(self, name): self.cmd('create', self.repository_location + '::' + name, src_dir) + def open_archive(self, name): + repository = Repository(self.repository_path) + with repository: + manifest, key = Manifest.load(repository) + archive = Archive(repository, key, manifest, name) + return archive, repository + class ArchiverTestCase(ArchiverTestCaseBase): @@ -1037,6 +1044,30 @@ class ArchiverTestCase(ArchiverTestCaseBase): sto = os.stat(out_fn) assert stat.S_ISFIFO(sto.st_mode) + @unittest.skipUnless(has_llfuse, 'llfuse not installed') + def test_fuse_allow_damaged_files(self): + self.cmd('init', self.repository_location) + self.create_src_archive('archive') + # Get rid of a chunk and repair it + archive, repository = self.open_archive('archive') + with repository: + for item in archive.iter_items(): + if item[b'path'].endswith('testsuite/archiver.py'): + repository.delete(item[b'chunks'][-1][0]) + path = item[b'path'] # store full path for later + break + else: + assert False # missed the file + repository.commit() + self.cmd('check', '--repair', self.repository_location, exit_code=0) + + mountpoint = os.path.join(self.tmpdir, 'mountpoint') + with self.fuse_mount(self.repository_location + '::archive', mountpoint): + with pytest.raises(OSError): + open(os.path.join(mountpoint, path)) + with self.fuse_mount(self.repository_location + '::archive', mountpoint, 'allow_damaged_files'): + open(os.path.join(mountpoint, path)).close() + def verify_aes_counter_uniqueness(self, method): seen = set() # Chunks already seen used = set() # counter values already used @@ -1117,13 +1148,6 @@ class ArchiverCheckTestCase(ArchiverTestCaseBase): self.create_src_archive('archive1') self.create_src_archive('archive2') - def open_archive(self, name): - repository = Repository(self.repository_path) - with repository: - manifest, key = Manifest.load(repository) - archive = Archive(repository, key, manifest, name) - return archive, repository - def test_check_usage(self): output = self.cmd('check', '-v', self.repository_location, exit_code=0) self.assert_in('Starting repository check', output)