Browse Source

extract: fs flags: use get/set to influence only specific flags, #9039, macOS only.

preserve UF_COMPRESSED and SF_DATALESS when restoring flags,
get-modify-set in macOS set_flags, keeping system-managed read-only flags.

(cherry picked from commit 83571aa00d744c20d93a2e6b2b69cbdaa0ea8f0b)
Thomas Waldmann 6 days ago
parent
commit
44ac21ff29

+ 2 - 1
src/borg/platform/__init__.py

@@ -35,7 +35,8 @@ elif is_darwin:  # pragma: darwin only
     from .darwin import listxattr, getxattr, setxattr
     from .darwin import listxattr, getxattr, setxattr
     from .darwin import acl_get, acl_set
     from .darwin import acl_get, acl_set
     from .darwin import is_darwin_feature_64_bit_inode, _get_birthtime_ns
     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 .base import SyncFile
     from .posix import process_alive, local_pid_alive
     from .posix import process_alive, local_pid_alive
     from .posix import swidth
     from .posix import swidth

+ 64 - 0
src/borg/platform/darwin.pyx

@@ -198,3 +198,67 @@ def _get_birthtime_ns(path, follow_symlinks=False):
     if result != 0:
     if result != 0:
         raise OSError(errno.errno, os.strerror(errno.errno), os.fsdecode(path))
         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
     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 |= <uint32_t> 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 = <uint32_t> 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))

+ 29 - 0
src/borg/testsuite/archiver/extract_cmd_test.py

@@ -2,11 +2,13 @@ import errno
 import os
 import os
 import shutil
 import shutil
 import time
 import time
+import stat
 from unittest.mock import patch
 from unittest.mock import patch
 
 
 import pytest
 import pytest
 
 
 from ... import xattr
 from ... import xattr
+from ... import platform
 from ...chunkers import has_seek_hole
 from ...chunkers import has_seek_hole
 from ...constants import *  # NOQA
 from ...constants import *  # NOQA
 from ...helpers import EXIT_WARNING, BackupPermissionError, bin_to_hex
 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.
     # 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):
 def test_overwrite(archivers, request):
     archiver = request.getfixturevalue(archivers)
     archiver = request.getfixturevalue(archivers)
     if archiver.EXE:
     if archiver.EXE: