diff --git a/src/borg/hlfuse.py b/src/borg/hlfuse.py index 242100757e..84d9704b12 100644 --- a/src/borg/hlfuse.py +++ b/src/borg/hlfuse.py @@ -29,6 +29,15 @@ BLOCK_SIZE = 512 # Standard filesystem block size for st_blocks and statfs +def fuse_options(options): + """Convert a libfuse option list (e.g. ["ro", "fsname=borgfs"]) to mfusepy keyword arguments.""" + kwargs = {} + for option in options: + key, sep, value = option.partition("=") + kwargs[key] = value if sep else True + return kwargs + + class borgfs(hlfuse.Operations): """Export archive contents as a FUSE filesystem""" @@ -64,9 +73,13 @@ def mount(self, mountpoint, mount_options, foreground=False, show_rc=False): lock_refreshing_thread = LockRefresher(self._repository.info, sleep_interval=60, lock=self._repo_lock) lock_refreshing_thread.start() try: - # Run the FUSE main loop in foreground (we might be daemonized already or not) + # Run the FUSE main loop in foreground (we might be daemonized already or not). + # mfusepy takes the libfuse options as keyword arguments (a flag option as True); + # raw_fi=True makes it pass the fuse_file_info struct to open/read/release/getattr. + fuse_kwargs = fuse_options(options) + fuse_kwargs.update(raw_fi=True, foreground=True, use_ino=True) with signal_handler("SIGUSR1", self.sig_info_handler), signal_handler("SIGINFO", self.sig_info_handler): - hlfuse.FUSE(self, mountpoint, options, foreground=True, use_ino=True) + hlfuse.FUSE(self, mountpoint, **fuse_kwargs) finally: lock_refreshing_thread.terminate() @@ -128,9 +141,10 @@ def statfs(self, path): "f_namemax": 255, # == NAME_MAX (depends on archive source OS / FS) } - def getattr(self, path, fh=None): - # use the file handle if we have one, to avoid the path lookup - node = self._node_from_handle(fh) if fh is not None else self._find_node(path) + def getattr(self, path, fi=None): + # use the file handle (in the fuse_file_info struct, see raw_fi in mount) if we have one, + # to avoid the path lookup. + node = self._node_from_handle(fi.fh) if fi is not None else self._find_node(path) return self._stat(node) def listxattr(self, path): diff --git a/src/borg/testsuite/archiver/mount_cmds_test.py b/src/borg/testsuite/archiver/mount_cmds_test.py index e6a2af35d4..ed444c89a7 100644 --- a/src/borg/testsuite/archiver/mount_cmds_test.py +++ b/src/borg/testsuite/archiver/mount_cmds_test.py @@ -8,6 +8,7 @@ import os import stat import sys +import time import pytest @@ -332,6 +333,30 @@ def test_fuse_allow_damaged_files(archivers, request): assert data.endswith(b"\0\0") +@pytest.mark.skipif(not has_any_fuse, reason="FUSE not available") +def test_fuse_read_to_eof_after_attr_timeout(archivers, request): + """Reading up to EOF after the attribute timeout must work, and the mount must be read-only. + + When a read reaches EOF after the (default 1 s) attribute timeout, the kernel asks for the + attributes of the *open* file first (fgetattr, with the file handle). This used to fail with + EINVAL on the mfusepy backend, and that backend also used to drop all libfuse mount options + (the mount was not even read-only), see the mfusepy FUSE() call in hlfuse.py. + """ + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + create_regular_file(archiver.input_path, "file", size=64 * 1024) + cmd(archiver, "create", "archive", "input") + mountpoint = os.path.join(archiver.tmpdir, "mountpoint") + with fuse_mount(archiver, mountpoint, "-a", "archive"): + assert os.statvfs(mountpoint).f_flag & os.ST_RDONLY, "libfuse options (ro) not applied" + with open(os.path.join(mountpoint, "archive", "input", "file"), "rb", buffering=0) as f: + assert len(f.read(4096)) == 4096 + time.sleep(1.5) # longer than the default attr_timeout of 1 s + # a read reaching EOF makes the kernel revalidate the size of the *open* file (fgetattr). + # use a raw read: an fstat (as f.read() does) would refresh the attributes without a handle. + assert len(os.read(f.fileno(), 64 * 1024)) == 64 * 1024 - 4096 + + @pytest.mark.skipif(not has_any_fuse, reason="FUSE not available") def test_fuse_mount_options(archivers, request): archiver = request.getfixturevalue(archivers)