diff --git a/src/borg/archive.py b/src/borg/archive.py index 2aac5afbe5..4ba8859391 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1487,6 +1487,17 @@ def s_to_ns(s): if name in ph: ns = s_to_ns(ph[name]) setattr(item, name, ns) + xattrs = StableDict() + for key, value in ph.items(): + if key.startswith(SCHILY_XATTR): + key = key.removeprefix(SCHILY_XATTR) + # the tarfile code gives us str keys and str values, + # but we need bytes keys and bytes values. + bkey = key.encode("utf-8", errors="surrogateescape") + bvalue = value.encode("utf-8", errors="surrogateescape") + xattrs[bkey] = bvalue + if xattrs: + item.xattrs = xattrs yield item, status # if we get here, "with"-block worked ok without error/exception, the item was processed ok... self.add_item(item, stats=self.stats) diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index 6ede4b348d..c4798c2e26 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -211,6 +211,13 @@ def item_to_paxheaders(format, item): if hasattr(item, name): ns = getattr(item, name) ph[name] = str(ns / 1e9) + if hasattr(item, "xattrs"): + for bkey, bvalue in item.xattrs.items(): + # we have bytes key and bytes value, but the tarfile code + # expects str key and str value. + key = SCHILY_XATTR + bkey.decode("utf-8", errors="surrogateescape") + value = bvalue.decode("utf-8", errors="surrogateescape") + ph[key] = value if format == "BORG": # BORG format additions ph["BORG.item.version"] = "1" # BORG.item.meta - just serialize all metadata we have: @@ -355,6 +362,7 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): | BORG | BORG specific, like PAX | all as supported by borg | +--------------+---------------------------+----------------------------+ | PAX | POSIX.1-2001 (pax) format | GNU + atime/ctime/mtime ns | + | | | + xattrs | +--------------+---------------------------+----------------------------+ | GNU | GNU tar format | mtime s, no atime/ctime, | | | | no ACLs/xattrs/bsdflags | @@ -396,7 +404,7 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): "--tar-format", metavar="FMT", dest="tar_format", - default="GNU", + default="PAX", choices=("BORG", "PAX", "GNU"), action=Highlander, help="select tar format: BORG, PAX or GNU", diff --git a/src/borg/constants.py b/src/borg/constants.py index 6d042d013e..36017d44f8 100644 --- a/src/borg/constants.py +++ b/src/borg/constants.py @@ -121,6 +121,9 @@ # similar to above, but for bigger granularity / clock differences TIME_DIFFERS2_NS = 3000000000 +# tar related +SCHILY_XATTR = "SCHILY.xattr." # xattr key prefix in tar PAX headers + # return codes returned by borg command EXIT_SUCCESS = 0 # everything done, no problems EXIT_WARNING = 1 # reached normal end of operation, but there were issues (generic warning) diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index ff28bc12ed..f377f35038 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -4,9 +4,11 @@ import pytest +from ... import xattr from ...constants import * # NOQA from .. import changedir -from . import assert_dirs_equal, _extract_hardlinks_setup, cmd, create_test_files, requires_hardlinks, RK_ENCRYPTION +from . import assert_dirs_equal, _extract_hardlinks_setup, cmd, requires_hardlinks, RK_ENCRYPTION +from . import create_test_files, create_regular_file from . import generate_archiver_tests pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary") # NOQA @@ -219,3 +221,22 @@ def test_roundtrip_pax_borg(archivers, request): with changedir(archiver.output_path): cmd(archiver, "extract", "dst") assert_dirs_equal("input", "output/input") + + +def test_roundtrip_pax_xattrs(archivers, request): + archiver = request.getfixturevalue(archivers) + if not xattr.is_enabled(archiver.input_path): + pytest.skip("xattrs not supported") + create_regular_file(archiver.input_path, "file") + original_path = os.path.join(archiver.input_path, "file") + xa_key, xa_value = b"user.xattrtest", b"not valid utf-8: \xff" + xattr.setxattr(original_path.encode(), xa_key, xa_value) + cmd(archiver, "repo-create", "--encryption=none") + cmd(archiver, "create", "src", "input") + cmd(archiver, "export-tar", "src", "xattrs.tar", "--tar-format=PAX") + cmd(archiver, "import-tar", "dst", "xattrs.tar") + with changedir(archiver.output_path): + cmd(archiver, "extract", "dst") + extracted_path = os.path.abspath("input/file") + xa_value_extracted = xattr.getxattr(extracted_path.encode(), xa_key) + assert xa_value_extracted == xa_value