Browse Source

macOS: retrieve `birthtime` in nanosecond precision via system call, fixes #8724

Xiaocheng Song 1 month ago
parent
commit
0d9f375fdd

+ 8 - 7
src/borg/archive.py

@@ -33,7 +33,7 @@ from .hashindex import ChunkIndex, ChunkIndexEntry
 from .helpers import HardLinkManager
 from .helpers import ChunkIteratorFileWrapper, open_item
 from .helpers import Error, IntegrityError, set_ec
-from .platform import uid2user, user2uid, gid2group, group2gid
+from .platform import uid2user, user2uid, gid2group, group2gid, get_birthtime_ns
 from .helpers import parse_timestamp, archive_ts_now
 from .helpers import OutputTimestamp, format_timedelta, format_file_size, file_status, FileSize
 from .helpers import safe_encode, make_path_safe, remove_surrogates, text_to_json, join_cmd, remove_dotdot_prefixes
@@ -1076,7 +1076,7 @@ class MetadataCollector:
         self.noxattrs = noxattrs
         self.nobirthtime = nobirthtime
 
-    def stat_simple_attrs(self, st):
+    def stat_simple_attrs(self, st, path, fd=None):
         attrs = {}
         attrs["mode"] = st.st_mode
         # borg can work with archives only having mtime (very old borg archives do not have
@@ -1087,9 +1087,10 @@ class MetadataCollector:
             attrs["atime"] = safe_ns(st.st_atime_ns)
         if not self.noctime:
             attrs["ctime"] = safe_ns(st.st_ctime_ns)
-        if not self.nobirthtime and hasattr(st, "st_birthtime"):
-            # sadly, there's no stat_result.st_birthtime_ns
-            attrs["birthtime"] = safe_ns(int(st.st_birthtime * 10**9))
+        if not self.nobirthtime:
+            birthtime_ns = get_birthtime_ns(st, path, fd=fd)
+            if birthtime_ns is not None:
+                attrs["birthtime"] = safe_ns(birthtime_ns)
         attrs["uid"] = st.st_uid
         attrs["gid"] = st.st_gid
         if not self.numeric_ids:
@@ -1123,7 +1124,7 @@ class MetadataCollector:
         return attrs
 
     def stat_attrs(self, st, path, fd=None):
-        attrs = self.stat_simple_attrs(st)
+        attrs = self.stat_simple_attrs(st, path, fd=fd)
         attrs.update(self.stat_ext_attrs(st, path, fd=fd))
         return attrs
 
@@ -1366,7 +1367,7 @@ class FilesystemObjectProcessors:
             with OsOpen(path=path, parent_fd=parent_fd, name=name, flags=flags, noatime=True) as fd:
                 with backup_io("fstat"):
                     st = stat_update_check(st, os.fstat(fd))
-                item.update(self.metadata_collector.stat_simple_attrs(st))
+                item.update(self.metadata_collector.stat_simple_attrs(st, path, fd=fd))
                 is_special_file = is_special(st.st_mode)
                 if is_special_file:
                     # we process a special file like a regular file. reflect that in mode,

+ 13 - 0
src/borg/platform/__init__.py

@@ -34,6 +34,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 is_darwin_feature_64_bit_inode, _get_birthtime_ns
     from .base import set_flags, get_flags
     from .base import SyncFile
     from .posix import process_alive, local_pid_alive
@@ -61,3 +62,15 @@ else:  # pragma: win32 only
     from .windows import process_alive, local_pid_alive
     from .base import swidth
     from .windows import uid2user, user2uid, gid2group, group2gid, getosusername
+
+
+def get_birthtime_ns(st, path, fd=None):
+    if hasattr(st, "st_birthtime_ns"):
+        # added in Python 3.12 but not always available.
+        return st.st_birthtime_ns
+    elif is_darwin and is_darwin_feature_64_bit_inode:
+        return _get_birthtime_ns(fd or path, follow_symlinks=False)
+    elif hasattr(st, "st_birthtime"):
+        return int(st.st_birthtime * 10**9)
+    else:
+        return None

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

@@ -2,6 +2,7 @@ import os
 
 from libc.stdint cimport uint32_t
 from libc cimport errno
+from posix.time cimport timespec
 
 from .posix import user2uid, group2gid
 from ..helpers import safe_decode, safe_encode
@@ -9,6 +10,18 @@ from .xattr import _listxattr_inner, _getxattr_inner, _setxattr_inner, split_str
 
 API_VERSION = '1.2_05'
 
+cdef extern from *:
+    """
+    #ifdef _DARWIN_FEATURE_64_BIT_INODE
+    #define DARWIN_FEATURE_64_BIT_INODE_DEFINED 1
+    #else
+    #define DARWIN_FEATURE_64_BIT_INODE_DEFINED 0
+    #endif
+    """
+    int DARWIN_FEATURE_64_BIT_INODE_DEFINED
+
+is_darwin_feature_64_bit_inode = DARWIN_FEATURE_64_BIT_INODE_DEFINED != 0
+
 cdef extern from "sys/xattr.h":
     ssize_t c_listxattr "listxattr" (const char *path, char *list, size_t size, int flags)
     ssize_t c_flistxattr "flistxattr" (int filedes, char *list, size_t size, int flags)
@@ -37,6 +50,14 @@ cdef extern from "sys/acl.h":
     char *acl_to_text(acl_t acl, ssize_t *len_p)
     int ACL_TYPE_EXTENDED
 
+cdef extern from "sys/stat.h":
+    cdef struct stat:
+        timespec st_birthtimespec
+
+    int c_stat "stat" (const char *path, stat *buf)
+    int c_lstat "lstat" (const char *path, stat *buf)
+    int c_fstat "fstat" (int filedes, stat *buf)
+
 
 def listxattr(path, *, follow_symlinks=False):
     def func(path, buf, size):
@@ -161,3 +182,20 @@ def acl_set(path, item, numeric_ids=False, fd=None):
                     raise OSError(errno.errno, os.strerror(errno.errno), os.fsdecode(path))
         finally:
             acl_free(acl)
+
+
+def _get_birthtime_ns(path, follow_symlinks=False):
+    if isinstance(path, str):
+        path = os.fsencode(path)
+    cdef stat stat_info
+    cdef int result
+    if isinstance(path, int):
+        result = c_fstat(path, &stat_info)
+    else:
+        if follow_symlinks:
+            result = c_stat(path, &stat_info)
+        else:
+            result = c_lstat(path, &stat_info)
+    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

+ 4 - 3
src/borg/testsuite/archiver/extract_cmd_test.py

@@ -13,6 +13,7 @@ from ...helpers import EXIT_WARNING, BackupPermissionError, bin_to_hex
 from ...helpers import flags_noatime, flags_normal
 from .. import changedir, same_ts_ns
 from .. import are_symlinks_supported, are_hardlinks_supported, is_utime_fully_supported, is_birthtime_fully_supported
+from ...platform import get_birthtime_ns
 from ...platformflags import is_darwin, is_win32
 from . import (
     RK_ENCRYPTION,
@@ -585,20 +586,20 @@ def test_extract_xattrs_resourcefork(archivers, request):
     input_path = os.path.abspath("input/file")
     xa_key, xa_value = b"com.apple.ResourceFork", b"whatshouldbehere"  # issue #7234
     xattr.setxattr(input_path.encode(), xa_key, xa_value)
-    birthtime_expected = os.stat(input_path).st_birthtime
+    birthtime_expected = get_birthtime_ns(os.stat(input_path), input_path)
     mtime_expected = os.stat(input_path).st_mtime_ns
     # atime_expected = os.stat(input_path).st_atime_ns
     cmd(archiver, "create", "test", "input")
     with changedir("output"):
         cmd(archiver, "extract", "test")
         extracted_path = os.path.abspath("input/file")
-        birthtime_extracted = os.stat(extracted_path).st_birthtime
+        birthtime_extracted = get_birthtime_ns(os.stat(extracted_path), extracted_path)
         mtime_extracted = os.stat(extracted_path).st_mtime_ns
         # atime_extracted = os.stat(extracted_path).st_atime_ns
         xa_value_extracted = xattr.getxattr(extracted_path.encode(), xa_key)
     assert xa_value_extracted == xa_value
     # cope with small birthtime deviations of less than 1000ns:
-    assert -1000 <= (birthtime_extracted - birthtime_expected) * 1e9 <= 1000
+    assert birthtime_extracted == birthtime_expected
     assert mtime_extracted == mtime_expected
     # assert atime_extracted == atime_expected  # still broken, but not really important.