diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f2649cca5..133eb66ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: toxenv: docs - os: ubuntu-22.04 python-version: '3.10' - toxenv: py310-fuse2 + toxenv: py310-fuse3 - os: ubuntu-22.04 python-version: '3.11' toxenv: py311-fuse3 diff --git a/src/borg/fuse.py b/src/borg/fuse.py index 166142b147..b5c44ad2e7 100644 --- a/src/borg/fuse.py +++ b/src/borg/fuse.py @@ -44,7 +44,7 @@ def async_wrapper(fn): from .helpers import msgpack from .helpers.lrucache import LRUCache from .item import Item -from .platform import uid2user, gid2group +from .platform import uid2user, gid2group, get_binary_acl from .platformflags import is_darwin from .repository import Repository from .remote import RemoteRepository @@ -57,6 +57,7 @@ def fuse_main(): except KeyboardInterrupt: return SIGINT except: # noqa + logger.exception("Exception in fuse_main:") return -1 # avoid colliding with signal numbers else: return None @@ -454,6 +455,8 @@ def _process_inner(self, name, parent_inode): class FuseOperations(llfuse.Operations, FuseBackend): """Export archive as a FUSE filesystem""" + enable_acl = True + def __init__(self, manifest, args, decrypted_repository): llfuse.Operations.__init__(self) FuseBackend.__init__(self, manifest, args, decrypted_repository) @@ -630,13 +633,23 @@ def getattr(self, inode, ctx=None): @async_wrapper def listxattr(self, inode, ctx=None): item = self.get_item(inode) - return item.get("xattrs", {}).keys() + xattrs = list(item.get("xattrs", {}).keys()) + if "acl_access" in item: + xattrs.append(b"system.posix_acl_access") + if "acl_default" in item: + xattrs.append(b"system.posix_acl_default") + return xattrs @async_wrapper def getxattr(self, inode, name, ctx=None): item = self.get_item(inode) try: - return item.get("xattrs", {})[name] or b"" + if name == b"system.posix_acl_access": + return get_binary_acl(item, "acl_access", self.numeric_ids) + elif name == b"system.posix_acl_default": + return get_binary_acl(item, "acl_default", self.numeric_ids) + else: + return item.get("xattrs", {})[name] or b"" except KeyError: raise llfuse.FUSEError(llfuse.ENOATTR) from None diff --git a/src/borg/platform/__init__.py b/src/borg/platform/__init__.py index abb9f429f8..6d87f55284 100644 --- a/src/borg/platform/__init__.py +++ b/src/borg/platform/__init__.py @@ -13,7 +13,7 @@ if is_linux: # pragma: linux only from .linux import API_VERSION as OS_API_VERSION from .linux import listxattr, getxattr, setxattr - from .linux import acl_get, acl_set + from .linux import acl_get, acl_set, get_binary_acl from .linux import set_flags, get_flags from .linux import SyncFile from .posix import process_alive, local_pid_alive @@ -23,7 +23,7 @@ elif is_freebsd: # pragma: freebsd only from .freebsd import API_VERSION as OS_API_VERSION from .freebsd import listxattr, getxattr, setxattr - from .freebsd import acl_get, acl_set + from .freebsd import acl_get, acl_set, get_binary_acl from .base import set_flags, get_flags from .base import SyncFile from .posix import process_alive, local_pid_alive @@ -33,7 +33,7 @@ elif is_darwin: # pragma: darwin only from .darwin import API_VERSION as OS_API_VERSION from .darwin import listxattr, getxattr, setxattr - from .darwin import acl_get, acl_set + from .darwin import acl_get, acl_set, get_binary_acl from .darwin import is_darwin_feature_64_bit_inode, _get_birthtime_ns from .base import set_flags, get_flags from .base import SyncFile @@ -45,7 +45,7 @@ # Generic code for all other POSIX OSes OS_API_VERSION = API_VERSION from .base import listxattr, getxattr, setxattr - from .base import acl_get, acl_set + from .base import acl_get, acl_set, get_binary_acl from .base import set_flags, get_flags from .base import SyncFile from .posix import process_alive, local_pid_alive diff --git a/src/borg/platform/base.py b/src/borg/platform/base.py index 5798607b5d..858d9f9b9c 100644 --- a/src/borg/platform/base.py +++ b/src/borg/platform/base.py @@ -80,6 +80,13 @@ def acl_set(path, item, numeric_ids=False, fd=None): """ +def get_binary_acl(item, key, numeric_ids=False): + """ + Get binary ACL data for item and key (acl name). + """ + raise KeyError(key) + + try: from os import lchflags # type: ignore[attr-defined] diff --git a/src/borg/platform/darwin.pyx b/src/borg/platform/darwin.pyx index 0181a19280..015b5cddab 100644 --- a/src/borg/platform/darwin.pyx +++ b/src/borg/platform/darwin.pyx @@ -5,6 +5,7 @@ from libc cimport errno from posix.time cimport timespec from .posix import user2uid, group2gid +from .base import get_binary_acl # dummy from ..helpers import safe_decode, safe_encode from .xattr import _listxattr_inner, _getxattr_inner, _setxattr_inner, split_string0 diff --git a/src/borg/platform/freebsd.pyx b/src/borg/platform/freebsd.pyx index 7e9460ce93..12badd46cc 100644 --- a/src/borg/platform/freebsd.pyx +++ b/src/borg/platform/freebsd.pyx @@ -4,6 +4,7 @@ import stat from libc cimport errno from .posix import posix_acl_use_stored_uid_gid +from .base import get_binary_acl # dummy from ..helpers import safe_encode, safe_decode from .xattr import _listxattr_inner, _getxattr_inner, _setxattr_inner, split_lstring diff --git a/src/borg/platform/linux.pyx b/src/borg/platform/linux.pyx index 5ea9370cb5..a9c18e9be7 100644 --- a/src/borg/platform/linux.pyx +++ b/src/borg/platform/linux.pyx @@ -1,3 +1,5 @@ +from cpython.bytes cimport PyBytes_FromStringAndSize + import os import re import stat @@ -48,6 +50,7 @@ cdef extern from "sys/acl.h": int acl_set_fd(int fd, acl_t acl) acl_t acl_from_text(const char *buf) char *acl_to_text(acl_t acl, ssize_t *len) + ssize_t acl_size(acl_t) cdef extern from "acl/libacl.h": int acl_extended_file_nofollow(const char *path) @@ -321,6 +324,31 @@ def acl_set(path, item, numeric_ids=False, fd=None): acl_free(default_acl) +def get_binary_acl(item, key, numeric_ids=False): + assert key in ("acl_access", "acl_default") + cdef acl_t acl = NULL + cdef ssize_t size + + if numeric_ids: + converter = posix_acl_use_stored_uid_gid + else: + converter = acl_use_local_uid_gid + acl_text = item.get(key) + if acl_text is None: + raise KeyError(key) + try: + acl = acl_from_text(converter(acl_text)) + if acl == NULL: + raise OSError(errno.errno, os.strerror(errno.errno), os.fsdecode(item.path)) + size = acl_size(acl) + if size < 0: + raise OSError(errno.errno, "Failed to get ACL size") + # Create a bytes object from the ACL data structure: + return PyBytes_FromStringAndSize( acl, size) + finally: + acl_free(acl) + + cdef _sync_file_range(fd, offset, length, flags): assert offset & PAGE_MASK == 0, "offset %d not page-aligned" % offset assert length & PAGE_MASK == 0, "length %d not page-aligned" % length diff --git a/src/borg/testsuite/archiver/mount_cmds_test.py b/src/borg/testsuite/archiver/mount_cmds_test.py index 88dd1d83a4..f57d8c23bf 100644 --- a/src/borg/testsuite/archiver/mount_cmds_test.py +++ b/src/borg/testsuite/archiver/mount_cmds_test.py @@ -12,7 +12,7 @@ from .. import has_lchflags, llfuse from .. import changedir, no_selinux, same_ts_ns from .. import are_symlinks_supported, are_hardlinks_supported, are_fifos_supported -from ..platform.platform_test import fakeroot_detected +from ..platform.platform_test import fakeroot_detected, skipif_not_linux, skipif_acls_not_working from . import RK_ENCRYPTION, cmd, assert_dirs_equal, create_regular_file, create_src_archive, open_archive, src_file from . import requires_hardlinks, _extract_hardlinks_setup, fuse_mount, create_test_files, generate_archiver_tests @@ -271,6 +271,56 @@ def test_fuse_mount_options(archivers, request): assert sorted(os.listdir(os.path.join(mountpoint))) == [] +@pytest.mark.skipif(not llfuse, reason="llfuse not installed") +@skipif_not_linux +@skipif_acls_not_working +def test_fuse_acl_support(archivers, request): + """Test that ACLs are correctly exposed through the FUSE interface.""" + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + + # Create a file with access ACL + file_path = os.path.join(archiver.input_path, "aclfile") + with open(file_path, "w") as f: + f.write("test content") + access_acl = b"user::rw-\ngroup::r--\nmask::rw-\nother::---\nuser:root:rw-:0\ngroup:root:r--:0\n" + platform.acl_set(file_path, {"acl_access": access_acl}) + + # Create a directory with default ACL + dir_path = os.path.join(archiver.input_path, "acldir") + os.mkdir(dir_path) + default_acl = b"user::rw-\ngroup::r--\nmask::rw-\nother::---\nuser:root:r--:0\ngroup:root:r--:0\n" + platform.acl_set(dir_path, {"acl_access": access_acl, "acl_default": default_acl}) + + # Create an archive + cmd(archiver, "create", "test", "input") + + # Mount the archive + mountpoint = os.path.join(archiver.tmpdir, "mountpoint") + with fuse_mount(archiver, mountpoint, "-a", "test"): + # Verify file ACLs are preserved + mounted_file = os.path.join(mountpoint, "test", "input", "aclfile") + assert os.path.exists(mounted_file) + assert os.path.isfile(mounted_file) + file_acl = {} + platform.acl_get(mounted_file, file_acl, os.stat(mounted_file)) + assert "acl_access" in file_acl + assert b"user::rw-" in file_acl["acl_access"] + assert b"user:root:rw-" in file_acl["acl_access"] + # Verify directory ACLs are preserved + mounted_dir = os.path.join(mountpoint, "test", "input", "acldir") + assert os.path.exists(mounted_dir) + assert os.path.isdir(mounted_dir) + dir_acl = {} + platform.acl_get(mounted_dir, dir_acl, os.stat(mounted_dir)) + assert "acl_access" in dir_acl + assert "acl_default" in dir_acl + assert b"user::rw-" in dir_acl["acl_access"] + assert b"user:root:rw-" in dir_acl["acl_access"] + assert b"user::rw-" in dir_acl["acl_default"] + assert b"user:root:r--" in dir_acl["acl_default"] + + @pytest.mark.skipif(not llfuse, reason="llfuse not installed") def test_migrate_lock_alive(archivers, request): """Both old_id and new_id must not be stale during lock migration / daemonization."""