Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions src/borg/fuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions src/borg/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/borg/platform/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
1 change: 1 addition & 0 deletions src/borg/platform/darwin.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/borg/platform/freebsd.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions src/borg/platform/linux.pyx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from cpython.bytes cimport PyBytes_FromStringAndSize

import os
import re
import stat
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(<bytes>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(<char *> acl, size)
Comment on lines +343 to +347
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit dirty, but there is no official API call for this.

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
Expand Down
52 changes: 51 additions & 1 deletion src/borg/testsuite/archiver/mount_cmds_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down
Loading