From 83571aa00d744c20d93a2e6b2b69cbdaa0ea8f0b Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Wed, 29 Oct 2025 20:56:29 +0100 Subject: [PATCH] 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. --- src/borg/platform/__init__.py | 1 + src/borg/platform/darwin.pyx | 64 ++++++++++++++++++++++++++++++++++ src/borg/testsuite/archiver.py | 23 ++++++++++++ 3 files changed, 88 insertions(+) diff --git a/src/borg/platform/__init__.py b/src/borg/platform/__init__.py index 632339449..3cece7821 100644 --- a/src/borg/platform/__init__.py +++ b/src/borg/platform/__init__.py @@ -41,6 +41,7 @@ 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 .darwin import set_flags def get_birthtime_ns(st, path, fd=None): diff --git a/src/borg/platform/darwin.pyx b/src/borg/platform/darwin.pyx index 32a69e566..d0e3f948c 100644 --- a/src/borg/platform/darwin.pyx +++ b/src/borg/platform/darwin.pyx @@ -195,3 +195,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.py b/src/borg/testsuite/archiver.py index b69dcebcf..7e29c6719 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -1534,6 +1534,29 @@ class ArchiverTestCase(ArchiverTestCaseBase): assert same_ts_ns(mtime_extracted, mtime_expected) # assert same_ts_ns(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(self): + if not has_lchflags or not hasattr(stat, 'UF_APPEND'): + pytest.skip('BSD flags or UF_APPEND not supported on this platform') + # create a file and set the append flag on it + self.create_regular_file('appendflag', size=1) + src_path = os.path.join(self.input_path, 'appendflag') + platform.set_flags(src_path, stat.UF_APPEND) + # 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 + self.cmd('init', '--encryption=repokey', self.repository_location) + archive = self.repository_location + '::test' + self.cmd('create', archive, 'input') + with changedir('output'): + self.cmd('extract', archive) + out_path = os.path.join('input', 'appendflag') + st2 = os.lstat(out_path) + flags = platform.get_flags(out_path, st2) + assert (flags & stat.UF_APPEND) == stat.UF_APPEND + def test_path_normalization(self): self.cmd('init', '--encryption=repokey', self.repository_location) self.create_regular_file('dir1/dir2/file', size=1024 * 80)