Repository: borgbackup/borg

Issue:
extract: original mtime not preserved on files with ResourceFork
<!--
Thank you for reporting an issue.

*IMPORTANT* -  *before* creating a new issue please look around:
 - Borgbackup documentation: http://borgbackup.readthedocs.io/en/stable/index.html
 - FAQ: https://borgbackup.readthedocs.io/en/stable/faq.html
 and
 - open issues in Github tracker: https://github.com/borgbackup/borg/issues
  
If you cannot find a similar problem, then create a new issue.

Please fill in as much of the template as possible.
-->

## Have you checked borgbackup docs, FAQ, and open Github issues?

Yes

## Is this a BUG / ISSUE report or a QUESTION?

Bug. Originally logged under Vorta bug [#939](https://github.com/borgbase/vorta/issues/939).

## System information. For client/server mode post info for both machines.

Macbook Air, M1. 

#### Your borg version (borg -V).

1.2.3

#### Operating system (distribution) and version.

macOS 12.6.2

#### Hardware / network configuration, and filesystems used.

Local APFS (encrypted)

#### How much data is handled by borg?

n/a

#### Full borg commandline that lead to the problem (leave away excludes and passwords)

borg extract

## Describe the problem you're observing.

Any file that has macOS xattr com.apple.ResourceFork is restored with a "modified" date equal to the date and time of the restore and not the original modified time of the file.  Example below. 

```
$ touch test1.txt  //create test file 1
$ touch test2.txt  //create test file 2
$ xattr -w com.apple.ResourceFork mytestrf test1.txt  //Add com.apple.ResourceFork to test1.txt
$ xattr -p com.apple.ResourceFork test1.txt           //Check xattr is added
mytestrf

$ ls -l test*.txt
-rw-r--r--@ 1 rxgh99  admin  0 Dec 29 00:32 test1.txt  <-- Original modified date/time of test1
-rw-r--r--  1 rxgh99  admin  0 Dec 29 00:31 test2.txt

$ borg create test-repo::xattr2 test*.txt

$ borg list test-repo::xattr2
-rw-r--r-- rxgh99   admin         0 Thu, 2022-12-29 00:32:03 test1.txt  <-- As captured in the repo
-rw-r--r-- rxgh99   admin         0 Thu, 2022-12-29 00:31:56 test2.txt

$ cd restore
$ borg extract ../test-repo::xattr2

$ date
Thu Dec 29 00:41:36 PST 2022

$ ls -l
-rw-r--r--@ 1 rxgh99  admin  0 Dec 29 00:41 test1.txt  <-- Modified date/time at restore
-rw-r--r--  1 rxgh99  admin  0 Dec 29 00:31 test2.txt

$ xattr -p com.apple.ResourceFork test1.txt  //Check xattr is restored
mytestrf
```

#### Can you reproduce the problem? If so, describe how. If not, describe troubleshooting steps you took before opening the issue.

Yes. Any restore of a file that includes com.apple.ResourceFork results in this behavior. 

#### Include any warning/errors/backtraces from the system logs

No warnings or errors. 

<!--

If this complaint relates to borg performance, please include CRUD benchmark
results and any steps you took to troubleshoot.
How to run benchmark: http://borgbackup.readthedocs.io/en/stable/usage/benchmark.html

*IMPORTANT* - Please mark logs and text output from terminal commands 
or else Github will not display them correctly. 
An example is provided below.

Example:
```
this is an example how log text should be marked (wrap it with ```)
```
-->



Discussion:
Thanks for this report. Does this happen also if only any other xattr is set on a file?
I have not tested all xattrs, but based on my (limited) testing it only appears to happen when com.apple.ResourceFork is present as an xattr. 

```
WITH com.apple.ResourceFork
$ borg list test-repo::xattr3
-rw-r----- rxgh99   admin   1522481 Fri, 2012-06-15 18:26:28 VFX_Manual.pdf  //com.apple.ResourceFork present
-rw-r----- rxgh99   admin    277504 Tue, 2011-02-01 20:55:39 WI_setup.doc    //com.apple.ResourceFork present
-rw-r----- rxgh99   admin   1522481 Fri, 2012-06-15 18:26:28 test-norf.pdf   //no xattrs
-rw-r--r-- rxgh99   admin         0 Thu, 2022-12-29 00:32:03 test1.txt       //com.apple.ResourceFork present
-rw-r--r-- rxgh99   admin         0 Thu, 2022-12-29 00:31:56 test2.txt       //no xattrs

$ ls -l restore3
-rw-r-----@ 1 rxgh99  admin  1522481 Dec 29 11:01 VFX_Manual.pdf  <-- date at restore time
-rw-r-----@ 1 rxgh99  admin   277504 Dec 29 11:01 WI_setup.doc    <-- date at restore time
-rw-r-----@ 1 rxgh99  admin  1522481 Jun 15  2012 test-norf.pdf
-rw-r--r--@ 1 rxgh99  admin        0 Dec 29 11:01 test1.txt       <-- date at restore time
-rw-r--r--  1 rxgh99  admin        0 Dec 29 00:31 test2.txt  

restore3 $ xattr WI*
com.apple.ResourceFork
com.apple.metadata:kMDItemWhereFroms

REMOVED com.apple.ResourceFork from WI_setup.doc
$ borg list test-repo::xattr4
-rw-r----- rxgh99   admin   1522481 Fri, 2012-06-15 18:26:28 VFX_Manual.pdf //com.apple.ResourceFork still present
-rw-r----- rxgh99   admin    277504 Tue, 2011-02-01 20:55:39 WI_setup.doc   //Removed com.apple.ResourceFork
-rw-r----- rxgh99   admin   1522481 Fri, 2012-06-15 18:26:28 test-norf.pdf
-rw-r--r-- rxgh99   admin         0 Thu, 2022-12-29 00:32:03 test1.txt
-rw-r--r-- rxgh99   admin         0 Thu, 2022-12-29 00:31:56 test2.txt

$ ls -l restore4
total 6512
-rw-r-----@ 1 rxgh99  admin  1522481 Dec 29 11:03 VFX_Manual.pdf  <-- date at restore time
-rw-r-----@ 1 rxgh99  admin   277504 Feb  1  2011 WI_setup.doc    <-- original date
-rw-r-----@ 1 rxgh99  admin  1522481 Jun 15  2012 test-norf.pdf
-rw-r--r--@ 1 rxgh99  admin        0 Dec 29 11:03 test1.txt       <-- date at restore time
-rw-r--r--  1 rxgh99  admin        0 Dec 29 00:31 test2.txt

restore4 $ xattr VFX*
com.apple.Preview.UIstate.v1
com.apple.ResourceFork
com.apple.metadata:kMDItemWhereFroms

restore4 $ xattr WI*
com.apple.metadata:kMDItemWhereFroms

```
Hmm, there is no code in borg that special-cases ResourceFork.

So, is it maybe macOS "jumping on" that freshly extracted file and touching it, modifying its timestamp? Maybe some indexing or security stuff?

That I do not know - I was wondering if it was a "timing" issue of some sort, between the extract process and, as you suggest, some OS activities. But then why might the OS only touch those with com.apple.ResourceFork xattr attribute and not others? It's an old attribute, for sure. 

I previously tried a similar process with Duplicacy and Arq. Duplicacy showed the same behavior as here but Arq restored the original modified date.
Is there something like linux's `strace` that you could use to see what syscalls `borg extract` is making, just to confirm whether it is borg or the OS?
I noticed that borg seems to set the mtime before setting the xattrs.  Would it help to do it in the other order?  Does MacOS update the mtime when that particular xattr is set?
https://github.com/borgbackup/borg/blob/1.2.3/src/borg/archive.py#L885

this is how borg 1.2.3 extracts the metadata (after extracting file data, but before closing the file).
@jdchristensen yeah, changing order might help.

but if there is some race with macOS touching fresh files, it would depend on who wins the race.

also it does not explain why it only happens with ResourceFork xattr entry.
From samba git:

On Darwin other than all the normal filesystem operations, 'Finder' (like
Explorer in Windows but a little more) keeps its information in two extended
attributes named 'com.apple.FinderInfo' and 'com.apple.ResourceFork'.

If these xattrs are not implemented the filesystem won't be shown on Finder, and if they
are not implemented properly there may be issues when some of the file operations
are done through GUI of Finder. But when a filesystem is used over mountpoint in a
terminal, everything is fine and these xattrs are not required.

Note: I am trying to write a test for this.

Update: test reproduces the issue. not only mtime is spoiled, but also atime. birthtime is ok.

Same test succeeds with another xattr name, so it is really specific to `com.apple.ResourceFork`.
> Is there something like linux's `strace` that you could use to see what syscalls `borg extract` is making, just to confirm whether it is borg or the OS?

There is dtruss that encapsulates dtrace, but need to remove SIP protection to run. 

Resolving patch:
diff --git a/src/borg/archive.py b/src/borg/archive.py
--- a/src/borg/archive.py
+++ b/src/borg/archive.py
@@ -913,6 +913,15 @@ def restore_attrs(self, path, item, symlink=False, fd=None):
                 except NotImplementedError:
                     if not symlink:
                         os.chmod(path, item.mode)
+            if not self.noacls:
+                acl_set(path, item, self.numeric_ids, fd=fd)
+            if not self.noxattrs:
+                # chown removes Linux capabilities, so set the extended attributes at the end, after chown, since they include
+                # the Linux capabilities in the "security.capability" attribute.
+                warning = xattr.set_all(fd or path, item.get('xattrs', {}), follow_symlinks=False)
+                if warning:
+                    set_ec(EXIT_WARNING)
+            # set timestamps rather late
             mtime = item.mtime
             if 'atime' in item:
                 atime = item.atime
@@ -939,14 +948,6 @@ def restore_attrs(self, path, item, symlink=False, fd=None):
             except OSError:
                 # some systems don't support calling utime on a symlink
                 pass
-            if not self.noacls:
-                acl_set(path, item, self.numeric_ids, fd=fd)
-            if not self.noxattrs:
-                # chown removes Linux capabilities, so set the extended attributes at the end, after chown, since they include
-                # the Linux capabilities in the "security.capability" attribute.
-                warning = xattr.set_all(fd or path, item.get('xattrs', {}), follow_symlinks=False)
-                if warning:
-                    set_ec(EXIT_WARNING)
             # bsdflags include the immutable flag and need to be set last:
             if not self.noflags and 'bsdflags' in item:
                 try:
diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py
--- a/src/borg/testsuite/archiver.py
+++ b/src/borg/testsuite/archiver.py
@@ -56,7 +56,7 @@
 from . import has_lchflags, llfuse
 from . import BaseTestCase, changedir, environment_variable, no_selinux
 from . import are_symlinks_supported, are_hardlinks_supported, are_fifos_supported, is_utime_fully_supported, is_birthtime_fully_supported
-from .platform import fakeroot_detected
+from .platform import fakeroot_detected, is_darwin
 from .upgrader import make_attic_repo
 from . import key
 
@@ -1424,6 +1424,29 @@ def patched_setxattr_EACCES(*args, **kwargs):
                 assert 'when setting extended attribute user.attribute:' in out
             assert os.path.isfile(input_abspath)
 
+    @pytest.mark.skipif(not is_darwin, reason='only for macOS')
+    def test_extract_xattrs_resourcefork(self):
+        self.create_regular_file('file')
+        self.cmd('init', self.repository_location, '-e' 'none')
+        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
+        mtime_expected = os.stat(input_path).st_mtime_ns
+        # atime_expected = os.stat(input_path).st_atime_ns
+        self.cmd('create', self.repository_location + '::test', 'input')
+        with changedir('output'):
+            self.cmd('extract', self.repository_location + '::test')
+            extracted_path = os.path.abspath('input/file')
+            birthtime_extracted = os.stat(extracted_path).st_birthtime
+            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
+        assert birthtime_extracted == birthtime_expected
+        assert mtime_extracted == mtime_expected
+        # assert atime_extracted == atime_expected  # still broken, but not really important.
+
     def test_path_normalization(self):
         self.cmd('init', '--encryption=repokey', self.repository_location)
         self.create_regular_file('dir1/dir2/file', size=1024 * 80)


Test patch: