From 0a663a2aa02e1e24737f66274c77f490406c076e Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 18 Aug 2026 16:43:55 +0200 Subject: [PATCH] import-tar: import Solaris tar extended attributes, fixes #8479 Solaris/illumos tar and pax archive extended attributes as pairs of type E members (a header giving parent path and attribute name, then the value), regardless of the selected archive format. Parse these and store them as xattrs of the parent item, consistent with how borg create archives extended attributes on those platforms (names verbatim, SUNWattr_* system attributes excluded, 16 MiB value limit). A parent's xattr members trail the parent member - for directories, its whole subtree - so item storage is deferred via a pending stack bounded by directory nesting depth. As a consequence, a directory item is now stored after its subtree. Type E members of other origins (e.g. IBM i pax), hard-linked attributes, attributes of attributes, oversized values and unsafe parent paths are skipped, summarized in one warning per reason instead of the previous per-member warning spam. Co-Authored-By: Claude Fable 5 --- docs/usage/import-tar.rst.inc | 7 + src/borg/archiver/tar_cmds.py | 171 +++++++++++++- src/borg/testsuite/archiver/tar_cmds_test.py | 232 +++++++++++++++++++ 3 files changed, 407 insertions(+), 3 deletions(-) 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"}}