diff --git a/docs/usage/import-tar.rst.inc b/docs/usage/import-tar.rst.inc index b8e0bf9c72..43de7438f5 100644 --- a/docs/usage/import-tar.rst.inc +++ b/docs/usage/import-tar.rst.inc @@ -125,6 +125,13 @@ import-tar reads these tar formats: - UNIX V7 tar - SunOS tar with extended attributes +Extended attributes archived by Solaris/illumos tar or pax (special member +type "E") are imported as xattrs of the respective archive item (matching how +borg create archives them on those platforms). System attributes +(``SUNWattr_*``), hard-linked attributes and attributes of attributes are not +imported. Members of other/unknown vendor-specific types are skipped and +reported in a summarizing warning at the end. + To import multiple tarballs into a single archive, they can be simply concatenated (e.g. using "cat") into a single file, and imported with an ``--ignore-zeros`` option to skip through the stop markers between them. \ No newline at end of file diff --git a/src/borg/archiver/tar_cmds.py b/src/borg/archiver/tar_cmds.py index 18fd8f3feb..b9bd4a29eb 100644 --- a/src/borg/archiver/tar_cmds.py +++ b/src/borg/archiver/tar_cmds.py @@ -2,9 +2,11 @@ import contextlib import logging import os +import posixpath import stat import sys import tarfile +from collections import Counter if sys.version_info >= (3, 14): from compression import zstd @@ -24,11 +26,13 @@ from ..helpers import archivename_validator, comment_validator, PathSpec, ChunkerParams, CompressionSpec from ..helpers import FilesystemPathSpec from ..helpers import remove_surrogates +from ..helpers import StableDict, make_path_safe from ..helpers import timestamp, archive_ts_now from ..helpers import basic_json_data, json_print from ..helpers import log_multi from ..helpers.argparsing import ArgumentParser from ..manifest import Manifest +from ..platform.solaris import SYSATTR_PREFIX, XATTR_SIZE_LIMIT from ._common import with_repository, with_archive, Highlander, define_exclusion_group from ._common import build_matcher, build_filter @@ -341,6 +345,146 @@ def create_zstd_filter(stream, stream_close, decompress): stream.close() +XATTR_HDRTYPE = b"E" # Solaris tar/pax extended attribute member, see #8479 +SUN_XATTR_HDR_SIZE_LIMIT = 2**16 # sanity limit for xattr header members, real ones are ~100 bytes + + +def parse_sun_xattr_hdr(payload): + """Parse the payload of a Solaris tar extended attribute header member, see #8479. + + Returns (typeflag, names, hardlinked): the typeflag of the attribute file, the + NUL-separated path segments (parent file path, attribute name, ...) as bytes and + whether the attribute is a hard link to another attribute. + Returns None if the payload is not a Solaris xattr header (e.g. IBM i pax uses + tarinfo type b'E' with a different, proprietary payload). + """ + # layout (numbers are NUL-terminated ASCII decimals): + # h_version[7] "1.0", h_size[10], h_component_len[10], h_link_component_len[10], + # then one section per attribute path: h_namesz[7], h_typeflag[1], h_names[h_namesz]. + if len(payload) < 46 or not payload.startswith(b"1.0\x00"): + return None + + def num(offset, width): + field = payload[offset : offset + width].split(b"\x00", 1)[0] + return int(field) if field.isdigit() else None + + h_size, component_len, link_len, namesz = num(7, 10), num(17, 10), num(27, 10), num(37, 7) + if None in (h_size, component_len, link_len, namesz): + return None + if h_size != len(payload) or 45 + namesz > len(payload): + return None + typeflag = payload[44:45] + names = payload[45 : 45 + namesz].split(b"\x00") + while names and names[-1] == b"": + names.pop() + if len(names) < 2: + return None + return typeflag, names, link_len > 0 + + +class DeferredItemAdder: + """add_item wrapper deferring item storage, so that Solaris tar extended attributes, + whose members trail the parent member (for a directory: its whole subtree), can still + be attached to the parent item, see #8479. + + A directory stays pending while members inside it are processed (memory use is thus + bounded by directory nesting depth), any other item only until the next item arrives. + """ + + def __init__(self, add_item): + self._add_item = add_item + self._pending = [] # stack of (item, add_item kwargs) + + @staticmethod + def _covers(dir_path, path): + # whether an item at dir_path is a directory ancestor of an item at path + return (dir_path == "." and path != ".") or path.startswith(dir_path + "/") + + def _flush_finished(self, path, *, keep_path_item=False): + # store pending items that cannot receive xattrs anymore once a member at *path* arrived + while self._pending: + item, kw = self._pending[-1] + if keep_path_item and item.path == path: + break + if stat.S_ISDIR(item.mode) and self._covers(item.path, path): + break + self._pending.pop() + self._add_item(item, **kw) + + def add(self, item, **kw): + self._flush_finished(item.path) + self._pending.append((item, kw)) + + def attach_xattr(self, path, name, value): + """Attach a name/value xattr to the pending item at *path*, return False if there is none.""" + self._flush_finished(path, keep_path_item=True) + for item, _ in reversed(self._pending): + if item.path == path: + if "xattrs" in item: + item.xattrs[name] = value # merge - PAX headers may already have set xattrs + else: + item.xattrs = StableDict({name: value}) + return True + return False + + def flush(self): + while self._pending: + item, kw = self._pending.pop() + self._add_item(item, **kw) + + +def process_sun_xattrs(tar, tarinfo, adder, skipped): + """Process a Solaris tar extended attribute header member and its value member, see #8479. + + Returns (status, pushback, hit_eof): the file status to display for the header member, + a member consumed by lookahead that still must be dispatched normally (or None) and + whether tar.next() already returned None (end of the tar stream reached). + """ + hdr = None + if tarinfo.size <= SUN_XATTR_HDR_SIZE_LIMIT: + hdr = parse_sun_xattr_hdr(tar.extractfile(tarinfo).read()) + if hdr is None: + skipped["skipped unrecognized extended attribute members (tarinfo type b'E')"] += 1 + return "E", None, False + typeflag, names, hardlinked = hdr + value_ti = tar.next() + if value_ti is None: + skipped["skipped Solaris extended attribute headers without a value member"] += 1 + return "E", None, True + if value_ti.type != XATTR_HDRTYPE: + # not the expected value member - hand it back for normal dispatching + skipped["skipped Solaris extended attribute headers without a value member"] += 1 + return "E", value_ti, False + # from here on, the value member is consumed together with the header member. + attrname = names[1] + if typeflag == b"5" or attrname == b".": + # the hidden attribute directory itself, expected member, no borg representation + return None, None, False + if attrname.startswith(SYSATTR_PREFIX.encode()): + # OS-maintained system attributes, not user xattrs - same exclusion as borg create + return None, None, False + if len(names) > 2 or b"/" in attrname: + skipped["skipped Solaris extended attributes of extended attributes (unsupported)"] += 1 + return "E", None, False + if hardlinked: + skipped["skipped hard-linked Solaris extended attributes (unsupported)"] += 1 + return "E", None, False + if value_ti.size > XATTR_SIZE_LIMIT: # same limit as borg create uses on Solaris + skipped["skipped too big Solaris extended attribute values"] += 1 + return "E", None, False + try: + parent_path = names[0].decode(tar.encoding or "utf-8", "surrogateescape") + parent_path = make_path_safe(posixpath.normpath(parent_path)) + except ValueError: + skipped["skipped Solaris extended attributes with an unsafe parent path"] += 1 + return "E", None, False + value = tar.extractfile(value_ti).read() + if not adder.attach_xattr(parent_path, attrname, value): + skipped["skipped Solaris extended attributes without a parent item"] += 1 + return "E", None, False + return None, None, False + + class TarMixIn: @with_repository(compatibility=(Manifest.Operation.READ,)) @with_archive @@ -553,11 +697,12 @@ def _import_tar(self, args, repository, manifest, key, cache, tarstream): log_json=args.log_json, ) cp = ChunksProcessor(cache=cache, key=key, add_item=archive.add_item, rechunkify=False) + adder = DeferredItemAdder(archive.add_item) tfo = TarfileObjectProcessors( cache=cache, key=key, process_file_chunks=cp.process_file_chunks, - add_item=archive.add_item, + add_item=adder.add, chunker_params=args.chunker_params, show_progress=args.progress, log_json=args.log_json, @@ -566,7 +711,14 @@ def _import_tar(self, args, repository, manifest, key, cache, tarstream): tar = tarfile.open(fileobj=tarstream, mode="r|", ignore_zeros=args.ignore_zeros) - while tarinfo := tar.next(): + skipped = Counter() # skip reason -> count, summarized as warnings at the end + pushback = None # member consumed by xattr lookahead, still to be dispatched + hit_eof = False + while not hit_eof: + tarinfo = pushback or tar.next() + pushback = None + if not tarinfo: + break if tarinfo.isreg(): status = tfo.process_file(tarinfo=tarinfo, status="A", type=stat.S_IFREG, tar=tar) archive.stats.nfiles += 1 @@ -584,14 +736,20 @@ def _import_tar(self, args, repository, manifest, key, cache, tarstream): status = tfo.process_dev(tarinfo=tarinfo, status="c", type=stat.S_IFCHR) elif tarinfo.isfifo(): status = tfo.process_fifo(tarinfo=tarinfo, status="f", type=stat.S_IFIFO) + elif tarinfo.type == XATTR_HDRTYPE: + status, pushback, hit_eof = process_sun_xattrs(tar, tarinfo, adder, skipped) else: status = "E" - self.print_warning("%s: Unsupported tarinfo type %s", tarinfo.name, tarinfo.type) + skipped[f"skipped unsupported tarinfo type {tarinfo.type!r}"] += 1 self.print_file_status(status, tarinfo.name) + adder.flush() # This does not close the fileobj (tarstream) we passed to it -- a side effect of the | mode. tar.close() + for reason, count in sorted(skipped.items()): + self.print_warning("%s (%d members)", reason, count) + if args.progress: archive.stats.show_progress(final=True) archive.stats += tfo.stats @@ -749,6 +907,13 @@ def build_parser_tar(self, subparsers, common_parser, mid_common_parser): - UNIX V7 tar - SunOS tar with extended attributes + Extended attributes archived by Solaris/illumos tar or pax (special member + type "E") are imported as xattrs of the respective archive item (matching how + borg create archives them on those platforms). System attributes + (``SUNWattr_*``), hard-linked attributes and attributes of attributes are not + imported. Members of other/unknown vendor-specific types are skipped and + reported in a summarizing warning at the end. + To import multiple tarballs into a single archive, they can be simply concatenated (e.g. using "cat") into a single file, and imported with an ``--ignore-zeros`` option to skip through the stop markers between them. diff --git a/src/borg/testsuite/archiver/tar_cmds_test.py b/src/borg/testsuite/archiver/tar_cmds_test.py index 5a1ea8f4fb..447ba38365 100644 --- a/src/borg/testsuite/archiver/tar_cmds_test.py +++ b/src/borg/testsuite/archiver/tar_cmds_test.py @@ -1,3 +1,4 @@ +import io import os import random import shutil @@ -9,6 +10,7 @@ from ... import xattr from ...archiver.tar_cmds import chunks_to_sparse_info, gnu_sparse_10_map, SparseTarInfo +from ...archiver.tar_cmds import parse_sun_xattr_hdr, XATTR_HDRTYPE from ...constants import * # NOQA from ...helpers import Error from ...item import ChunkListEntry @@ -686,3 +688,233 @@ def set_acl(path, access=None, default=None): assert "acl_default" in extracted_dir_acl assert extracted_dir_acl["acl_default"] == dir_acl["acl_default"] assert b"user:root:r--" in dir_acl["acl_default"] + + +def make_sun_xattr_hdr_payload(parent, attrpath, typeflag=b"0", link_names=None): + """Byte-exact reimplementation of illumos tar's prepare_xattr(), see #8479.""" + # a "/" in attrpath separates an attribute of an attribute - stored as another NUL-separated segment + names = parent + b"\x00" + attrpath.replace(b"/", b"\x00", 1) + b"\x00" + complen = len(names) + 9 # 9 = sizeof(struct xattr_buf), incl. its 1 byte h_names placeholder + if link_names: + link = link_names[0] + b"\x00" + link_names[1] + b"\x00" + linklen = len(link) + 9 + else: + linklen = 0 + size = 37 + complen + linklen + buf = bytearray(size) # zero-filled, like the calloc() there + buf[0:4] = b"1.0\x00" + buf[7:17] = b"%09d\x00" % size + buf[17:27] = b"%09d\x00" % complen + buf[27:37] = b"%09d\x00" % linklen + buf[37:44] = b"%06d\x00" % len(names) + buf[44:45] = typeflag + buf[45 : 45 + len(names)] = names + if link_names: + offset = 37 + complen + buf[offset : offset + 7] = b"%06d\x00" % len(link) + buf[offset + 7 : offset + 8] = typeflag + buf[offset + 8 : offset + 8 + len(link)] = link + return bytes(buf) + + +def add_tar_member(tar, name, type, content=b""): + tarinfo = tarfile.TarInfo(name) + tarinfo.type = type + tarinfo.size = len(content) + tarinfo.mode = 0o755 if type == tarfile.DIRTYPE else 0o644 + tarinfo.mtime = 1234567890 + tar.addfile(tarinfo, io.BytesIO(content) if content else None) + + +def add_sun_xattr_pair(tar, parent, attrname, value, typeflag=b"0", link_names=None, hdr_payload=None): + # Solaris tar names xattr members with a decoy path, only the payloads matter. + if hdr_payload is None: + hdr_payload = make_sun_xattr_hdr_payload(parent, attrname, typeflag, link_names) + decoy = (b"/dev/null/" + attrname).decode("utf-8", "surrogateescape") + add_tar_member(tar, decoy + ".hdr", XATTR_HDRTYPE, hdr_payload) + add_tar_member(tar, decoy, XATTR_HDRTYPE, value) + + +def exported_xattrs(archiver, name): + """Return {path: {attr: value}} of all items with xattrs, platform-independently via a PAX export.""" + cmd(archiver, "export-tar", name, "xa.tar", "--tar-format=PAX") + result = {} + with tarfile.open("xa.tar") as tar: + for tarinfo in tar: + xa = { + key.removeprefix("SCHILY.xattr.").encode("utf-8", "surrogateescape"): value.encode( + "utf-8", "surrogateescape" + ) + for key, value in tarinfo.pax_headers.items() + if key.startswith("SCHILY.xattr.") + } + if xa: + result[tarinfo.name] = xa + return result + + +def test_parse_sun_xattr_hdr(): + # header payloads captured from a real Solaris 10 tar archive (see #8479) + payload_attr = ( + b"1.0\x00\x00\x00\x00000000076\x00000000039\x00000000000\x00000030\x000SolarisMetadata\x00ANewAttribute\x00\x00" + ) + assert parse_sun_xattr_hdr(payload_attr) == (b"0", [b"SolarisMetadata", b"ANewAttribute"], False) + assert make_sun_xattr_hdr_payload(b"SolarisMetadata", b"ANewAttribute") == payload_attr + payload_attrdir = ( + b"1.0\x00\x00\x00\x00000000064\x00000000027\x00000000000\x00000018\x005SolarisMetadata\x00.\x00\x00" + ) + assert parse_sun_xattr_hdr(payload_attrdir) == (b"5", [b"SolarisMetadata", b"."], False) + linked = make_sun_xattr_hdr_payload(b"file1", b"attr1", link_names=(b"file0", b"attr0")) + assert parse_sun_xattr_hdr(linked) == (b"0", [b"file1", b"attr1"], True) + assert parse_sun_xattr_hdr(b"") is None + assert parse_sun_xattr_hdr(b"\xfd\xff\x32\x00" + b"\x40" * 50) is None # IBM i pax style payload + assert parse_sun_xattr_hdr(payload_attr[:-10]) is None # truncated (h_size mismatch) + + +def test_import_tar_solaris_xattrs(archivers, request): + archiver = request.getfixturevalue(archivers) + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "file1", tarfile.REGTYPE, b"data1") + add_sun_xattr_pair(tar, b"file1", b".", b"", typeflag=b"5") # the hidden attr directory itself + add_sun_xattr_pair(tar, b"file1", b"SUNWattr_ro", b"sysattr stuff") # system attr + add_sun_xattr_pair(tar, b"file1", b"attr1", b"value1") + add_sun_xattr_pair(tar, b"file1", b"attr2", b"not valid utf-8: \xff") + add_tar_member(tar, "file2", tarfile.REGTYPE, b"data2") + cmd(archiver, "repo-create", "--encryption=none-sha256") + output = cmd(archiver, "import-tar", "dst", "sun.tar") + assert "skipped" not in output + files = cmd(archiver, "list", "dst", "--format", "{path}{NL}").splitlines() + assert set(files) == {"file1", "file2"} + assert exported_xattrs(archiver, "dst") == {"file1": {b"attr1": b"value1", b"attr2": b"not valid utf-8: \xff"}} + + +def test_import_tar_solaris_xattrs_dir(archivers, request): + archiver = request.getfixturevalue(archivers) + # a directory's xattr members only arrive after the directory's whole subtree + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "dir", tarfile.DIRTYPE) + add_tar_member(tar, "dir/sub", tarfile.DIRTYPE) + add_tar_member(tar, "dir/sub/file", tarfile.REGTYPE, b"data") + add_sun_xattr_pair(tar, b"dir/sub/file", b"fattr", b"fvalue") + add_sun_xattr_pair(tar, b"dir/sub", b"sattr", b"svalue") + add_sun_xattr_pair(tar, b"dir", b"dattr", b"dvalue") + cmd(archiver, "repo-create", "--encryption=none-sha256") + cmd(archiver, "import-tar", "dst", "sun.tar") + files = cmd(archiver, "list", "dst", "--format", "{path}{NL}").splitlines() + assert set(files) == {"dir", "dir/sub", "dir/sub/file"} + assert exported_xattrs(archiver, "dst") == { + "dir": {b"dattr": b"dvalue"}, + "dir/sub": {b"sattr": b"svalue"}, + "dir/sub/file": {b"fattr": b"fvalue"}, + } + + +def test_import_tar_solaris_xattrs_root_dir(archivers, request): + archiver = request.getfixturevalue(archivers) + # "tar cf x ." style archive: the root member "./" becomes item path ".", its xattrs arrive last + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "./", tarfile.DIRTYPE) + add_tar_member(tar, "./file", tarfile.REGTYPE, b"data") + add_sun_xattr_pair(tar, b".", b"rootattr", b"rootvalue") + cmd(archiver, "repo-create", "--encryption=none-sha256") + cmd(archiver, "import-tar", "dst", "sun.tar") + files = cmd(archiver, "list", "dst", "--format", "{path}{NL}").splitlines() + assert set(files) == {".", "file"} + assert exported_xattrs(archiver, "dst") == {".": {b"rootattr": b"rootvalue"}} + + +def test_import_tar_solaris_xattrs_nonascii(archivers, request): + archiver = request.getfixturevalue(archivers) + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "fö", tarfile.REGTYPE, b"data") + add_sun_xattr_pair(tar, "fö".encode(), "ättr".encode(), b"value") + cmd(archiver, "repo-create", "--encryption=none-sha256") + cmd(archiver, "import-tar", "dst", "sun.tar") + assert exported_xattrs(archiver, "dst") == {"fö": {"ättr".encode(): b"value"}} + + +def test_import_tar_solaris_xattrs_skipped(archivers, request): + archiver = request.getfixturevalue(archivers) + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "file1", tarfile.REGTYPE, b"data1") + # hard-linked attribute + add_sun_xattr_pair(tar, b"file1", b"lattr", b"", link_names=(b"file0", b"attr0")) + # attribute of an attribute (three name segments) + add_sun_xattr_pair(tar, b"file1", b"attr1/nested", b"v") + # hostile header: "/" kept inside the attribute name instead of segment splitting + unsplit = make_sun_xattr_hdr_payload(b"file1", b"attrX?nested").replace(b"?", b"/") + add_sun_xattr_pair(tar, b"file1", b"attrX", b"v", hdr_payload=unsplit) + # unsafe parent path + add_sun_xattr_pair(tar, b"../evil", b"attr2", b"v") + # parent path not in the archive (anymore) + add_sun_xattr_pair(tar, b"nosuchfile", b"attr3", b"v") + add_tar_member(tar, "file2", tarfile.REGTYPE, b"data2") + cmd(archiver, "repo-create", "--encryption=none-sha256") + output = cmd(archiver, "import-tar", "dst", "sun.tar", exit_code=1) + assert "skipped hard-linked Solaris extended attributes (unsupported) (1 members)" in output + assert "skipped Solaris extended attributes of extended attributes (unsupported) (2 members)" in output + assert "skipped Solaris extended attributes with an unsafe parent path (1 members)" in output + assert "skipped Solaris extended attributes without a parent item (1 members)" in output + files = cmd(archiver, "list", "dst", "--format", "{path}{NL}").splitlines() + assert set(files) == {"file1", "file2"} + assert exported_xattrs(archiver, "dst") == {} + + +def test_import_tar_type_E_fallback(archivers, request): + archiver = request.getfixturevalue(archivers) + # IBM i pax also uses tarinfo type b'E', but with a proprietary payload - and no pairing, + # so each member must be skipped on its own, without lookahead. + with tarfile.open("ibmi.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "file1", tarfile.REGTYPE, b"data1") + add_tar_member(tar, ".SUBJECT", XATTR_HDRTYPE, b"\xfd\xff\x32\x00" + b"\x40" * 50) + add_tar_member(tar, ".CODEPAGE", XATTR_HDRTYPE, b"\xfe\xff\x02\x00\x11\x01") + add_tar_member(tar, "file2", tarfile.REGTYPE, b"data2") + cmd(archiver, "repo-create", "--encryption=none-sha256") + output = cmd(archiver, "import-tar", "dst", "ibmi.tar", "--list", exit_code=1) + assert "skipped unrecognized extended attribute members (tarinfo type b'E') (2 members)" in output + assert "E .SUBJECT" in output + files = cmd(archiver, "list", "dst", "--format", "{path}{NL}").splitlines() + assert set(files) == {"file1", "file2"} + + +def test_import_tar_solaris_xattr_hdr_at_eof(archivers, request): + archiver = request.getfixturevalue(archivers) + # a lone xattr header member at the end of the tar must not crash the import + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "file1", tarfile.REGTYPE, b"data1") + add_tar_member(tar, "/dev/null/attr1.hdr", XATTR_HDRTYPE, make_sun_xattr_hdr_payload(b"file1", b"attr1")) + cmd(archiver, "repo-create", "--encryption=none-sha256") + output = cmd(archiver, "import-tar", "dst", "sun.tar", exit_code=1) + assert "skipped Solaris extended attribute headers without a value member (1 members)" in output + files = cmd(archiver, "list", "dst", "--format", "{path}{NL}").splitlines() + assert set(files) == {"file1"} + + +def test_import_tar_solaris_xattr_hdr_mispaired(archivers, request): + archiver = request.getfixturevalue(archivers) + # an xattr header member followed by a regular member: the regular member must not get lost + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "file1", tarfile.REGTYPE, b"data1") + add_tar_member(tar, "/dev/null/attr1.hdr", XATTR_HDRTYPE, make_sun_xattr_hdr_payload(b"file1", b"attr1")) + add_tar_member(tar, "file2", tarfile.REGTYPE, b"data2") + cmd(archiver, "repo-create", "--encryption=none-sha256") + output = cmd(archiver, "import-tar", "dst", "sun.tar", exit_code=1) + assert "skipped Solaris extended attribute headers without a value member (1 members)" in output + files = cmd(archiver, "list", "dst", "--format", "{path}{NL}").splitlines() + assert set(files) == {"file1", "file2"} + with changedir(archiver.output_path): + cmd(archiver, "extract", "dst") + with open("file2", "rb") as f: + assert f.read() == b"data2" + + +def test_import_tar_solaris_xattr_value_too_big(archivers, request): + archiver = request.getfixturevalue(archivers) + with tarfile.open("sun.tar", "w", format=tarfile.USTAR_FORMAT) as tar: + add_tar_member(tar, "file1", tarfile.REGTYPE, b"data1") + add_sun_xattr_pair(tar, b"file1", b"big", b"\x00" * (2**24 + 1)) + add_sun_xattr_pair(tar, b"file1", b"attr1", b"value1") + cmd(archiver, "repo-create", "--encryption=none-sha256") + output = cmd(archiver, "import-tar", "dst", "sun.tar", exit_code=1) + assert "skipped too big Solaris extended attribute values (1 members)" in output + assert exported_xattrs(archiver, "dst") == {"file1": {b"attr1": b"value1"}}