diff --git a/src/borg/platform/__init__.py b/src/borg/platform/__init__.py index abb9f429f..0929e8a2d 100644 --- a/src/borg/platform/__init__.py +++ b/src/borg/platform/__init__.py @@ -35,7 +35,8 @@ elif is_darwin: # pragma: darwin only from .darwin import listxattr, getxattr, setxattr from .darwin import acl_get, acl_set from .darwin import is_darwin_feature_64_bit_inode, _get_birthtime_ns - from .base import set_flags, get_flags + from .darwin import set_flags + from .base import get_flags from .base import SyncFile from .posix import process_alive, local_pid_alive from .posix import swidth diff --git a/src/borg/platform/darwin.pyx b/src/borg/platform/darwin.pyx index 0181a1928..a1af12582 100644 --- a/src/borg/platform/darwin.pyx +++ b/src/borg/platform/darwin.pyx @@ -198,3 +198,67 @@ def _get_birthtime_ns(path, follow_symlinks=False): if result != 0: raise OSError(errno.errno, os.strerror(errno.errno), os.fsdecode(path)) return stat_info.st_birthtimespec.tv_sec * 1_000_000_000 + stat_info.st_birthtimespec.tv_nsec + + +# macOS flags handling: only modify flags documented as settable; preserve all others, see #9090. +# The man page states UF_COMPRESSED and SF_DATALESS are internal flags and must not be modified +# from user space. We therefore only modify flags that are documented to be settable by owner or +# super-user and preserve everything else (including unknown or future flags). + +cdef extern from "sys/stat.h": + int chflags(const char *path, uint32_t flags) + int lchflags(const char *path, uint32_t flags) + int fchflags(int fd, uint32_t flags) + + +# Known-good settable flags from macOS chflags(2). We intentionally do NOT include +# internal flags like UF_COMPRESSED and SF_DATALESS. Resolved once at import time. +# getattr(..., 0) keeps this importable on non-Darwin platforms or Python versions +# missing some constants. +import stat as stat_mod + +SETTABLE_FLAG_NAMES = ( + # Owner-settable (UF_*) + 'UF_NODUMP', + 'UF_IMMUTABLE', + 'UF_APPEND', + 'UF_OPAQUE', + 'UF_NOUNLINK', + 'UF_HIDDEN', + # Super-user-settable (SF_*) + 'SF_ARCHIVED', + 'SF_IMMUTABLE', + 'SF_APPEND', + # SF_NOUNLINK exists on some BSDs; include defensively + 'SF_NOUNLINK', +) + +cdef uint32_t SETTABLE_FLAGS_MASK = 0 +for _name in SETTABLE_FLAG_NAMES: + SETTABLE_FLAGS_MASK |= getattr(stat_mod, _name, 0) + + +def set_flags(path, bsd_flags, fd=None): + """Set BSD-style flags on macOS, preserving system-managed read-only flags.""" + # Determine current flags. + try: + if fd is not None: + st = os.fstat(fd) + else: + st = os.lstat(path) + current = st.st_flags + except (OSError, AttributeError): + # We can't determine the current flags, so better give up than corrupting anything. + return + + new_flags = (current & ~SETTABLE_FLAGS_MASK) | (bsd_flags & SETTABLE_FLAGS_MASK) + + # Apply flags. + cdef uint32_t c_flags = new_flags + if fd is not None: + if fchflags(fd, c_flags) == -1: + raise OSError(errno.errno, os.strerror(errno.errno), path) + else: + path_bytes = os.fsencode(path) + if lchflags(path_bytes, c_flags) == -1: + raise OSError(errno.errno, os.strerror(errno.errno), os.fsdecode(path_bytes)) diff --git a/src/borg/testsuite/archiver/extract_cmd_test.py b/src/borg/testsuite/archiver/extract_cmd_test.py index c8369b554..918a40a30 100644 --- a/src/borg/testsuite/archiver/extract_cmd_test.py +++ b/src/borg/testsuite/archiver/extract_cmd_test.py @@ -2,11 +2,13 @@ import errno import os import shutil import time +import stat from unittest.mock import patch import pytest from ... import xattr +from ... import platform from ...chunkers import has_seek_hole from ...constants import * # NOQA from ...helpers import EXIT_WARNING, BackupPermissionError, bin_to_hex @@ -606,6 +608,33 @@ def test_extract_xattrs_resourcefork(archivers, request): # assert atime_extracted == atime_expected # still broken, but not really important. +@pytest.mark.skipif(not is_darwin, reason="only for macOS") +def test_extract_restores_append_flag(archivers, request): + archiver = request.getfixturevalue(archivers) + # create a file and set the append flag on it + create_regular_file(archiver.input_path, "appendflag", size=1) + src_path = os.path.abspath("input/appendflag") + if not hasattr(stat, "UF_APPEND"): + pytest.skip("UF_APPEND not available on this platform") + try: + platform.set_flags(src_path, stat.UF_APPEND) + except Exception: + pytest.skip("setting UF_APPEND not supported on this filesystem") + # Verify the flag actually got set; otherwise skip (filesystem may not support it) + st = os.lstat(src_path) + if (platform.get_flags(src_path, st) & stat.UF_APPEND) == 0: + pytest.skip("UF_APPEND not settable on this filesystem") + # archive and extract + cmd(archiver, "repo-create", "-e" "none") + cmd(archiver, "create", "test", "input") + with changedir("output"): + cmd(archiver, "extract", "test") + out_path = os.path.abspath("input/appendflag") + st2 = os.lstat(out_path) + flags = platform.get_flags(out_path, st2) + assert (flags & stat.UF_APPEND) == stat.UF_APPEND + + def test_overwrite(archivers, request): archiver = request.getfixturevalue(archivers) if archiver.EXE: