浏览代码

move tar commands to archiver.tar

Thomas Waldmann 2 年之前
父节点
当前提交
27b63e7c48
共有 4 个文件被更改,包括 600 次插入567 次删除
  1. 1 0
      setup.cfg
  2. 6 566
      src/borg/archiver/__init__.py
  3. 73 1
      src/borg/archiver/common.py
  4. 520 0
      src/borg/archiver/tar.py

+ 1 - 0
setup.cfg

@@ -119,6 +119,7 @@ per_file_ignores =
     src/borg/archiver/__init__.py:E402,E501,E722,E741,F405
     src/borg/archiver/common.py:E501,F405
     src/borg/archiver/debug.py:F405
+    src/borg/archiver/tar.py:F405
     src/borg/cache.py:E127,E128,E402,E501,E722,W504
     src/borg/fuse.py:E402,E501,E722,W504
     src/borg/fuse_impl.py:F811

+ 6 - 566
src/borg/archiver/__init__.py

@@ -5,7 +5,6 @@ import traceback
 
 try:
     import argparse
-    import base64
     import collections
     import configparser
     import faulthandler
@@ -21,7 +20,6 @@ try:
     import signal
     import stat
     import subprocess
-    import tarfile
     import textwrap
     import time
     from binascii import unhexlify
@@ -38,7 +36,7 @@ try:
     from .. import helpers
     from ..archive import Archive, ArchiveChecker, ArchiveRecreater, Statistics, is_special
     from ..archive import BackupError, BackupOSError, backup_io, OsOpen, stat_update_check
-    from ..archive import FilesystemObjectProcessors, TarfileObjectProcessors, MetadataCollector, ChunksProcessor
+    from ..archive import FilesystemObjectProcessors, MetadataCollector, ChunksProcessor
     from ..cache import Cache, assert_secure, SecurityManager
     from ..constants import *  # NOQA
     from ..compress import CompressionSpec
@@ -65,22 +63,13 @@ try:
     from ..helpers import ErrorIgnoringTextIOWrapper
     from ..helpers import ProgressIndicatorPercent
     from ..helpers import basic_json_data, json_print
-    from ..helpers import ChunkIteratorFileWrapper
-    from ..helpers import prepare_subprocess_env, create_filter_process
-    from ..helpers import dash_open
+    from ..helpers import prepare_subprocess_env
     from ..helpers import umount
     from ..helpers import flags_root, flags_dir, flags_special_follow, flags_special
     from ..helpers import msgpack
     from ..helpers import sig_int
     from ..helpers import iter_separated
-    from ..helpers import get_tar_filter
     from ..nanorst import rst_to_terminal
-    from ..patterns import (
-        ArgparsePatternAction,
-        ArgparseExcludeFileAction,
-        ArgparsePatternFileAction,
-        parse_exclude_pattern,
-    )
     from ..patterns import PatternMatcher
     from ..item import Item
     from ..platform import get_flags, SyncFile
@@ -122,9 +111,10 @@ def get_func(args):
 
 
 from .debug import DebugMixIn
+from .tar import TarMixIn
 
 
-class Archiver(DebugMixIn):
+class Archiver(DebugMixIn, TarMixIn):
     def __init__(self, lock_wait=None, prog=None):
         self.exit_code = EXIT_SUCCESS
         self.lock_wait = lock_wait
@@ -1196,194 +1186,6 @@ class Archiver(DebugMixIn):
             pi.finish()
         return self.exit_code
 
-    @with_repository(compatibility=(Manifest.Operation.READ,))
-    @with_archive
-    def do_export_tar(self, args, repository, manifest, key, archive):
-        """Export archive contents as a tarball"""
-        self.output_list = args.output_list
-
-        # A quick note about the general design of tar_filter and tarfile;
-        # The tarfile module of Python can provide some compression mechanisms
-        # by itself, using the builtin gzip, bz2 and lzma modules (and "tarmodes"
-        # such as "w:xz").
-        #
-        # Doing so would have three major drawbacks:
-        # For one the compressor runs on the same thread as the program using the
-        # tarfile, stealing valuable CPU time from Borg and thus reducing throughput.
-        # Then this limits the available options - what about lz4? Brotli? zstd?
-        # The third issue is that systems can ship more optimized versions than those
-        # built into Python, e.g. pigz or pxz, which can use more than one thread for
-        # compression.
-        #
-        # Therefore we externalize compression by using a filter program, which has
-        # none of these drawbacks. The only issue of using an external filter is
-        # that it has to be installed -- hardly a problem, considering that
-        # the decompressor must be installed as well to make use of the exported tarball!
-
-        filter = get_tar_filter(args.tarfile, decompress=False) if args.tar_filter == "auto" else args.tar_filter
-
-        tarstream = dash_open(args.tarfile, "wb")
-        tarstream_close = args.tarfile != "-"
-
-        with create_filter_process(filter, stream=tarstream, stream_close=tarstream_close, inbound=False) as _stream:
-            self._export_tar(args, archive, _stream)
-
-        return self.exit_code
-
-    def _export_tar(self, args, archive, tarstream):
-        matcher = self.build_matcher(args.patterns, args.paths)
-
-        progress = args.progress
-        output_list = args.output_list
-        strip_components = args.strip_components
-        hlm = HardLinkManager(id_type=bytes, info_type=str)  # hlid -> path
-
-        filter = self.build_filter(matcher, strip_components)
-
-        # The | (pipe) symbol instructs tarfile to use a streaming mode of operation
-        # where it never seeks on the passed fileobj.
-        tar_format = dict(GNU=tarfile.GNU_FORMAT, PAX=tarfile.PAX_FORMAT, BORG=tarfile.PAX_FORMAT)[args.tar_format]
-        tar = tarfile.open(fileobj=tarstream, mode="w|", format=tar_format)
-
-        if progress:
-            pi = ProgressIndicatorPercent(msg="%5.1f%% Processing: %s", step=0.1, msgid="extract")
-            pi.output("Calculating size")
-            extracted_size = sum(item.get_size() for item in archive.iter_items(filter))
-            pi.total = extracted_size
-        else:
-            pi = None
-
-        def item_content_stream(item):
-            """
-            Return a file-like object that reads from the chunks of *item*.
-            """
-            chunk_iterator = archive.pipeline.fetch_many([chunk_id for chunk_id, _ in item.chunks], is_preloaded=True)
-            if pi:
-                info = [remove_surrogates(item.path)]
-                return ChunkIteratorFileWrapper(
-                    chunk_iterator, lambda read_bytes: pi.show(increase=len(read_bytes), info=info)
-                )
-            else:
-                return ChunkIteratorFileWrapper(chunk_iterator)
-
-        def item_to_tarinfo(item, original_path):
-            """
-            Transform a Borg *item* into a tarfile.TarInfo object.
-
-            Return a tuple (tarinfo, stream), where stream may be a file-like object that represents
-            the file contents, if any, and is None otherwise. When *tarinfo* is None, the *item*
-            cannot be represented as a TarInfo object and should be skipped.
-            """
-            stream = None
-            tarinfo = tarfile.TarInfo()
-            tarinfo.name = item.path
-            tarinfo.mtime = item.mtime / 1e9
-            tarinfo.mode = stat.S_IMODE(item.mode)
-            tarinfo.uid = item.uid
-            tarinfo.gid = item.gid
-            tarinfo.uname = item.get("user", "")
-            tarinfo.gname = item.get("group", "")
-            # The linkname in tar has 2 uses:
-            # for symlinks it means the destination, while for hardlinks it refers to the file.
-            # Since hardlinks in tar have a different type code (LNKTYPE) the format might
-            # support hardlinking arbitrary objects (including symlinks and directories), but
-            # whether implementations actually support that is a whole different question...
-            tarinfo.linkname = ""
-
-            modebits = stat.S_IFMT(item.mode)
-            if modebits == stat.S_IFREG:
-                tarinfo.type = tarfile.REGTYPE
-                if "hlid" in item:
-                    linkname = hlm.retrieve(id=item.hlid)
-                    if linkname is not None:
-                        # the first hardlink was already added to the archive, add a tar-hardlink reference to it.
-                        tarinfo.type = tarfile.LNKTYPE
-                        tarinfo.linkname = linkname
-                    else:
-                        tarinfo.size = item.get_size()
-                        stream = item_content_stream(item)
-                        hlm.remember(id=item.hlid, info=item.path)
-                else:
-                    tarinfo.size = item.get_size()
-                    stream = item_content_stream(item)
-            elif modebits == stat.S_IFDIR:
-                tarinfo.type = tarfile.DIRTYPE
-            elif modebits == stat.S_IFLNK:
-                tarinfo.type = tarfile.SYMTYPE
-                tarinfo.linkname = item.source
-            elif modebits == stat.S_IFBLK:
-                tarinfo.type = tarfile.BLKTYPE
-                tarinfo.devmajor = os.major(item.rdev)
-                tarinfo.devminor = os.minor(item.rdev)
-            elif modebits == stat.S_IFCHR:
-                tarinfo.type = tarfile.CHRTYPE
-                tarinfo.devmajor = os.major(item.rdev)
-                tarinfo.devminor = os.minor(item.rdev)
-            elif modebits == stat.S_IFIFO:
-                tarinfo.type = tarfile.FIFOTYPE
-            else:
-                self.print_warning(
-                    "%s: unsupported file type %o for tar export", remove_surrogates(item.path), modebits
-                )
-                set_ec(EXIT_WARNING)
-                return None, stream
-            return tarinfo, stream
-
-        def item_to_paxheaders(format, item):
-            """
-            Transform (parts of) a Borg *item* into a pax_headers dict.
-            """
-            # PAX format
-            # ----------
-            # When using the PAX (POSIX) format, we can support some things that aren't possible
-            # with classic tar formats, including GNU tar, such as:
-            # - atime, ctime (DONE)
-            # - possibly Linux capabilities, security.* xattrs (TODO)
-            # - various additions supported by GNU tar in POSIX mode (TODO)
-            #
-            # BORG format
-            # -----------
-            # This is based on PAX, but additionally adds BORG.* pax headers.
-            # Additionally to the standard tar / PAX metadata and data, it transfers
-            # ALL borg item metadata in a BORG specific way.
-            #
-            ph = {}
-            # note: for mtime this is a bit redundant as it is already done by tarfile module,
-            #       but we just do it in our way to be consistent for sure.
-            for name in "atime", "ctime", "mtime":
-                if hasattr(item, name):
-                    ns = getattr(item, name)
-                    ph[name] = str(ns / 1e9)
-            if format == "BORG":  # BORG format additions
-                ph["BORG.item.version"] = "1"
-                # BORG.item.meta - just serialize all metadata we have:
-                meta_bin = msgpack.packb(item.as_dict())
-                meta_text = base64.b64encode(meta_bin).decode()
-                ph["BORG.item.meta"] = meta_text
-            return ph
-
-        for item in archive.iter_items(filter, preload=True):
-            orig_path = item.path
-            if strip_components:
-                item.path = os.sep.join(orig_path.split(os.sep)[strip_components:])
-            tarinfo, stream = item_to_tarinfo(item, orig_path)
-            if tarinfo:
-                if args.tar_format in ("BORG", "PAX"):
-                    tarinfo.pax_headers = item_to_paxheaders(args.tar_format, item)
-                if output_list:
-                    logging.getLogger("borg.output.list").info(remove_surrogates(orig_path))
-                tar.addfile(tarinfo, stream)
-
-        if pi:
-            pi.finish()
-
-        # This does not close the fileobj (tarstream) we passed to it -- a side effect of the | mode.
-        tar.close()
-
-        for pattern in matcher.get_unmatched_include_patterns():
-            self.print_warning("Include pattern '%s' never matched.", pattern)
-        return self.exit_code
-
     @with_repository(compatibility=(Manifest.Operation.READ,))
     @with_archive
     def do_diff(self, args, repository, manifest, key, archive):
@@ -1908,102 +1710,6 @@ class Archiver(DebugMixIn):
             cache.commit()
         return self.exit_code
 
-    @with_repository(cache=True, exclusive=True, compatibility=(Manifest.Operation.WRITE,))
-    def do_import_tar(self, args, repository, manifest, key, cache):
-        """Create a backup archive from a tarball"""
-        self.output_filter = args.output_filter
-        self.output_list = args.output_list
-
-        filter = get_tar_filter(args.tarfile, decompress=True) if args.tar_filter == "auto" else args.tar_filter
-
-        tarstream = dash_open(args.tarfile, "rb")
-        tarstream_close = args.tarfile != "-"
-
-        with create_filter_process(filter, stream=tarstream, stream_close=tarstream_close, inbound=True) as _stream:
-            self._import_tar(args, repository, manifest, key, cache, _stream)
-
-        return self.exit_code
-
-    def _import_tar(self, args, repository, manifest, key, cache, tarstream):
-        t0 = datetime.utcnow()
-        t0_monotonic = time.monotonic()
-
-        archive = Archive(
-            repository,
-            key,
-            manifest,
-            args.name,
-            cache=cache,
-            create=True,
-            checkpoint_interval=args.checkpoint_interval,
-            progress=args.progress,
-            chunker_params=args.chunker_params,
-            start=t0,
-            start_monotonic=t0_monotonic,
-            log_json=args.log_json,
-        )
-        cp = ChunksProcessor(
-            cache=cache,
-            key=key,
-            add_item=archive.add_item,
-            write_checkpoint=archive.write_checkpoint,
-            checkpoint_interval=args.checkpoint_interval,
-            rechunkify=False,
-        )
-        tfo = TarfileObjectProcessors(
-            cache=cache,
-            key=key,
-            process_file_chunks=cp.process_file_chunks,
-            add_item=archive.add_item,
-            chunker_params=args.chunker_params,
-            show_progress=args.progress,
-            log_json=args.log_json,
-            iec=args.iec,
-            file_status_printer=self.print_file_status,
-        )
-
-        tar = tarfile.open(fileobj=tarstream, mode="r|")
-
-        while True:
-            tarinfo = tar.next()
-            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
-            elif tarinfo.isdir():
-                status = tfo.process_dir(tarinfo=tarinfo, status="d", type=stat.S_IFDIR)
-            elif tarinfo.issym():
-                status = tfo.process_symlink(tarinfo=tarinfo, status="s", type=stat.S_IFLNK)
-            elif tarinfo.islnk():
-                # tar uses a hardlink model like: the first instance of a hardlink is stored as a regular file,
-                # later instances are special entries referencing back to the first instance.
-                status = tfo.process_hardlink(tarinfo=tarinfo, status="h", type=stat.S_IFREG)
-            elif tarinfo.isblk():
-                status = tfo.process_dev(tarinfo=tarinfo, status="b", type=stat.S_IFBLK)
-            elif tarinfo.ischr():
-                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)
-            else:
-                status = "E"
-                self.print_warning("%s: Unsupported tarinfo type %s", tarinfo.name, tarinfo.type)
-            self.print_file_status(status, tarinfo.name)
-
-        # This does not close the fileobj (tarstream) we passed to it -- a side effect of the | mode.
-        tar.close()
-
-        if args.progress:
-            archive.stats.show_progress(final=True)
-        archive.stats += tfo.stats
-        archive.save(comment=args.comment, timestamp=args.timestamp)
-        args.stats |= args.json
-        if args.stats:
-            if args.json:
-                json_print(basic_json_data(archive.manifest, cache=archive.cache, extra={"archive": archive}))
-            else:
-                log_multi(str(archive), str(archive.stats), logger=logging.getLogger("borg.output.stats"))
-
     @with_repository(manifest=False, exclusive=True)
     def do_with_lock(self, args, repository):
         """run a user specified command with the repository lock held"""
@@ -2740,6 +2446,7 @@ class Archiver(DebugMixIn):
     def build_parser(self):
 
         from .common import process_epilog
+        from .common import define_exclusion_group
 
         def define_common_options(add_common_option):
             add_common_option("-h", "--help", action="help", help="show this help message and exit")
@@ -2881,75 +2588,6 @@ class Archiver(DebugMixIn):
                 help="repository to use",
             )
 
-        def define_exclude_and_patterns(add_option, *, tag_files=False, strip_components=False):
-            add_option(
-                "-e",
-                "--exclude",
-                metavar="PATTERN",
-                dest="patterns",
-                type=parse_exclude_pattern,
-                action="append",
-                help="exclude paths matching PATTERN",
-            )
-            add_option(
-                "--exclude-from",
-                metavar="EXCLUDEFILE",
-                action=ArgparseExcludeFileAction,
-                help="read exclude patterns from EXCLUDEFILE, one per line",
-            )
-            add_option(
-                "--pattern",
-                metavar="PATTERN",
-                action=ArgparsePatternAction,
-                help="include/exclude paths matching PATTERN",
-            )
-            add_option(
-                "--patterns-from",
-                metavar="PATTERNFILE",
-                action=ArgparsePatternFileAction,
-                help="read include/exclude patterns from PATTERNFILE, one per line",
-            )
-
-            if tag_files:
-                add_option(
-                    "--exclude-caches",
-                    dest="exclude_caches",
-                    action="store_true",
-                    help="exclude directories that contain a CACHEDIR.TAG file "
-                    "(http://www.bford.info/cachedir/spec.html)",
-                )
-                add_option(
-                    "--exclude-if-present",
-                    metavar="NAME",
-                    dest="exclude_if_present",
-                    action="append",
-                    type=str,
-                    help="exclude directories that are tagged by containing a filesystem object with " "the given NAME",
-                )
-                add_option(
-                    "--keep-exclude-tags",
-                    dest="keep_exclude_tags",
-                    action="store_true",
-                    help="if tag objects are specified with ``--exclude-if-present``, "
-                    "don't omit the tag objects themselves from the backup archive",
-                )
-
-            if strip_components:
-                add_option(
-                    "--strip-components",
-                    metavar="NUMBER",
-                    dest="strip_components",
-                    type=int,
-                    default=0,
-                    help="Remove the specified number of leading path elements. "
-                    "Paths with fewer elements will be silently skipped.",
-                )
-
-        def define_exclusion_group(subparser, **kwargs):
-            exclude_group = subparser.add_argument_group("Exclusion options")
-            define_exclude_and_patterns(exclude_group.add_argument, **kwargs)
-            return exclude_group
-
         def define_archive_filters_group(subparser, *, sort_by=True, first_last=True):
             filters_group = subparser.add_argument_group(
                 "Archive filters", "Archive filters can be applied to repository targets."
@@ -4044,83 +3682,6 @@ class Archiver(DebugMixIn):
         )
         define_exclusion_group(subparser)
 
-        # borg export-tar
-        export_tar_epilog = process_epilog(
-            """
-        This command creates a tarball from an archive.
-
-        When giving '-' as the output FILE, Borg will write a tar stream to standard output.
-
-        By default (``--tar-filter=auto``) Borg will detect whether the FILE should be compressed
-        based on its file extension and pipe the tarball through an appropriate filter
-        before writing it to FILE:
-
-        - .tar.gz or .tgz: gzip
-        - .tar.bz2 or .tbz: bzip2
-        - .tar.xz or .txz: xz
-        - .tar.zstd: zstd
-        - .tar.lz4: lz4
-
-        Alternatively, a ``--tar-filter`` program may be explicitly specified. It should
-        read the uncompressed tar stream from stdin and write a compressed/filtered
-        tar stream to stdout.
-
-        Depending on the ``-tar-format`` option, these formats are created:
-
-        +--------------+---------------------------+----------------------------+
-        | --tar-format | Specification             | Metadata                   |
-        +--------------+---------------------------+----------------------------+
-        | BORG         | BORG specific, like PAX   | all as supported by borg   |
-        +--------------+---------------------------+----------------------------+
-        | PAX          | POSIX.1-2001 (pax) format | GNU + atime/ctime/mtime ns |
-        +--------------+---------------------------+----------------------------+
-        | GNU          | GNU tar format            | mtime s, no atime/ctime,   |
-        |              |                           | no ACLs/xattrs/bsdflags    |
-        +--------------+---------------------------+----------------------------+
-
-        A ``--sparse`` option (as found in borg extract) is not supported.
-
-        By default the entire archive is extracted but a subset of files and directories
-        can be selected by passing a list of ``PATHs`` as arguments.
-        The file selection can further be restricted by using the ``--exclude`` option.
-
-        For more help on include/exclude patterns, see the :ref:`borg_patterns` command output.
-
-        ``--progress`` can be slower than no progress display, since it makes one additional
-        pass over the archive metadata.
-        """
-        )
-        subparser = subparsers.add_parser(
-            "export-tar",
-            parents=[common_parser],
-            add_help=False,
-            description=self.do_export_tar.__doc__,
-            epilog=export_tar_epilog,
-            formatter_class=argparse.RawDescriptionHelpFormatter,
-            help="create tarball from archive",
-        )
-        subparser.set_defaults(func=self.do_export_tar)
-        subparser.add_argument(
-            "--tar-filter", dest="tar_filter", default="auto", help="filter program to pipe data through"
-        )
-        subparser.add_argument(
-            "--list", dest="output_list", action="store_true", help="output verbose list of items (files, dirs, ...)"
-        )
-        subparser.add_argument(
-            "--tar-format",
-            metavar="FMT",
-            dest="tar_format",
-            default="GNU",
-            choices=("BORG", "PAX", "GNU"),
-            help="select tar format: BORG, PAX or GNU",
-        )
-        subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name")
-        subparser.add_argument("tarfile", metavar="FILE", help='output tar file. "-" to write to stdout instead.')
-        subparser.add_argument(
-            "paths", metavar="PATH", nargs="*", type=str, help="paths to extract; patterns are supported"
-        )
-        define_exclusion_group(subparser, strip_components=True)
-
         # borg extract
         extract_epilog = process_epilog(
             """
@@ -5167,129 +4728,8 @@ class Archiver(DebugMixIn):
         subparser.add_argument("command", metavar="COMMAND", help="command to run")
         subparser.add_argument("args", metavar="ARGS", nargs=argparse.REMAINDER, help="command arguments")
 
-        # borg import-tar
-        import_tar_epilog = process_epilog(
-            """
-        This command creates a backup archive from a tarball.
-
-        When giving '-' as path, Borg will read a tar stream from standard input.
-
-        By default (--tar-filter=auto) Borg will detect whether the file is compressed
-        based on its file extension and pipe the file through an appropriate filter:
-
-        - .tar.gz or .tgz: gzip -d
-        - .tar.bz2 or .tbz: bzip2 -d
-        - .tar.xz or .txz: xz -d
-        - .tar.zstd: zstd -d
-        - .tar.lz4: lz4 -d
-
-        Alternatively, a --tar-filter program may be explicitly specified. It should
-        read compressed data from stdin and output an uncompressed tar stream on
-        stdout.
+        self.build_parser_tar(subparsers, common_parser, mid_common_parser)
 
-        Most documentation of borg create applies. Note that this command does not
-        support excluding files.
-
-        A ``--sparse`` option (as found in borg create) is not supported.
-
-        About tar formats and metadata conservation or loss, please see ``borg export-tar``.
-
-        import-tar reads these tar formats:
-
-        - BORG: borg specific (PAX-based)
-        - PAX: POSIX.1-2001
-        - GNU: GNU tar
-        - POSIX.1-1988 (ustar)
-        - UNIX V7 tar
-        - SunOS tar with extended attributes
-
-        """
-        )
-        subparser = subparsers.add_parser(
-            "import-tar",
-            parents=[common_parser],
-            add_help=False,
-            description=self.do_import_tar.__doc__,
-            epilog=import_tar_epilog,
-            formatter_class=argparse.RawDescriptionHelpFormatter,
-            help=self.do_import_tar.__doc__,
-        )
-        subparser.set_defaults(func=self.do_import_tar)
-        subparser.add_argument(
-            "--tar-filter",
-            dest="tar_filter",
-            default="auto",
-            action=Highlander,
-            help="filter program to pipe data through",
-        )
-        subparser.add_argument(
-            "-s",
-            "--stats",
-            dest="stats",
-            action="store_true",
-            default=False,
-            help="print statistics for the created archive",
-        )
-        subparser.add_argument(
-            "--list",
-            dest="output_list",
-            action="store_true",
-            default=False,
-            help="output verbose list of items (files, dirs, ...)",
-        )
-        subparser.add_argument(
-            "--filter",
-            dest="output_filter",
-            metavar="STATUSCHARS",
-            action=Highlander,
-            help="only display items with the given status characters",
-        )
-        subparser.add_argument("--json", action="store_true", help="output stats as JSON (implies --stats)")
-
-        archive_group = subparser.add_argument_group("Archive options")
-        archive_group.add_argument(
-            "--comment", dest="comment", metavar="COMMENT", default="", help="add a comment text to the archive"
-        )
-        archive_group.add_argument(
-            "--timestamp",
-            dest="timestamp",
-            type=timestamp,
-            default=None,
-            metavar="TIMESTAMP",
-            help="manually specify the archive creation date/time (UTC, yyyy-mm-ddThh:mm:ss format). "
-            "alternatively, give a reference file/directory.",
-        )
-        archive_group.add_argument(
-            "-c",
-            "--checkpoint-interval",
-            dest="checkpoint_interval",
-            type=int,
-            default=1800,
-            metavar="SECONDS",
-            help="write checkpoint every SECONDS seconds (Default: 1800)",
-        )
-        archive_group.add_argument(
-            "--chunker-params",
-            dest="chunker_params",
-            action=Highlander,
-            type=ChunkerParams,
-            default=CHUNKER_PARAMS,
-            metavar="PARAMS",
-            help="specify the chunker parameters (ALGO, CHUNK_MIN_EXP, CHUNK_MAX_EXP, "
-            "HASH_MASK_BITS, HASH_WINDOW_SIZE). default: %s,%d,%d,%d,%d" % CHUNKER_PARAMS,
-        )
-        archive_group.add_argument(
-            "-C",
-            "--compression",
-            metavar="COMPRESSION",
-            dest="compression",
-            type=CompressionSpec,
-            default=CompressionSpec("lz4"),
-            help="select compression algorithm, see the output of the " '"borg help compression" command for details.',
-        )
-
-        subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name")
-        subparser.add_argument("tarfile", metavar="TARFILE", help='input tar file. "-" to read from stdin instead.')
         return parser
 
     def get_args(self, argv, cmd):

+ 73 - 1
src/borg/archiver/common.py

@@ -10,8 +10,13 @@ from ..helpers import Error
 from ..helpers import Manifest
 from ..remote import RemoteRepository
 from ..repository import Repository
-
 from ..nanorst import rst_to_terminal
+from ..patterns import (
+    ArgparsePatternAction,
+    ArgparseExcludeFileAction,
+    ArgparsePatternFileAction,
+    parse_exclude_pattern,
+)
 
 from ..logger import create_logger
 
@@ -296,3 +301,70 @@ def process_epilog(epilog):
     if mode == "command-line":
         epilog = rst_to_terminal(epilog, rst_plain_text_references)
     return epilog
+
+
+def define_exclude_and_patterns(add_option, *, tag_files=False, strip_components=False):
+    add_option(
+        "-e",
+        "--exclude",
+        metavar="PATTERN",
+        dest="patterns",
+        type=parse_exclude_pattern,
+        action="append",
+        help="exclude paths matching PATTERN",
+    )
+    add_option(
+        "--exclude-from",
+        metavar="EXCLUDEFILE",
+        action=ArgparseExcludeFileAction,
+        help="read exclude patterns from EXCLUDEFILE, one per line",
+    )
+    add_option(
+        "--pattern", metavar="PATTERN", action=ArgparsePatternAction, help="include/exclude paths matching PATTERN"
+    )
+    add_option(
+        "--patterns-from",
+        metavar="PATTERNFILE",
+        action=ArgparsePatternFileAction,
+        help="read include/exclude patterns from PATTERNFILE, one per line",
+    )
+
+    if tag_files:
+        add_option(
+            "--exclude-caches",
+            dest="exclude_caches",
+            action="store_true",
+            help="exclude directories that contain a CACHEDIR.TAG file " "(http://www.bford.info/cachedir/spec.html)",
+        )
+        add_option(
+            "--exclude-if-present",
+            metavar="NAME",
+            dest="exclude_if_present",
+            action="append",
+            type=str,
+            help="exclude directories that are tagged by containing a filesystem object with " "the given NAME",
+        )
+        add_option(
+            "--keep-exclude-tags",
+            dest="keep_exclude_tags",
+            action="store_true",
+            help="if tag objects are specified with ``--exclude-if-present``, "
+            "don't omit the tag objects themselves from the backup archive",
+        )
+
+    if strip_components:
+        add_option(
+            "--strip-components",
+            metavar="NUMBER",
+            dest="strip_components",
+            type=int,
+            default=0,
+            help="Remove the specified number of leading path elements. "
+            "Paths with fewer elements will be silently skipped.",
+        )
+
+
+def define_exclusion_group(subparser, **kwargs):
+    exclude_group = subparser.add_argument_group("Exclusion options")
+    define_exclude_and_patterns(exclude_group.add_argument, **kwargs)
+    return exclude_group

+ 520 - 0
src/borg/archiver/tar.py

@@ -0,0 +1,520 @@
+import argparse
+import base64
+from datetime import datetime
+import logging
+import os
+import stat
+import tarfile
+import time
+
+from ..archive import Archive, TarfileObjectProcessors, ChunksProcessor
+from ..compress import CompressionSpec
+from ..constants import *  # NOQA
+from ..helpers import Manifest
+from ..helpers import HardLinkManager
+from ..helpers import ProgressIndicatorPercent
+from ..helpers import get_tar_filter
+from ..helpers import dash_open
+from ..helpers import msgpack
+from ..helpers import create_filter_process
+from ..helpers import ChunkIteratorFileWrapper
+from ..helpers import ChunkerParams
+from ..helpers import NameSpec
+from ..helpers import remove_surrogates
+from ..helpers import timestamp
+from ..helpers import basic_json_data, json_print
+from ..helpers import log_multi
+
+from .common import with_repository, with_archive, Highlander, define_exclusion_group
+
+from ..logger import create_logger
+
+logger = create_logger(__name__)
+
+
+class TarMixIn:
+    @with_repository(compatibility=(Manifest.Operation.READ,))
+    @with_archive
+    def do_export_tar(self, args, repository, manifest, key, archive):
+        """Export archive contents as a tarball"""
+        self.output_list = args.output_list
+
+        # A quick note about the general design of tar_filter and tarfile;
+        # The tarfile module of Python can provide some compression mechanisms
+        # by itself, using the builtin gzip, bz2 and lzma modules (and "tarmodes"
+        # such as "w:xz").
+        #
+        # Doing so would have three major drawbacks:
+        # For one the compressor runs on the same thread as the program using the
+        # tarfile, stealing valuable CPU time from Borg and thus reducing throughput.
+        # Then this limits the available options - what about lz4? Brotli? zstd?
+        # The third issue is that systems can ship more optimized versions than those
+        # built into Python, e.g. pigz or pxz, which can use more than one thread for
+        # compression.
+        #
+        # Therefore we externalize compression by using a filter program, which has
+        # none of these drawbacks. The only issue of using an external filter is
+        # that it has to be installed -- hardly a problem, considering that
+        # the decompressor must be installed as well to make use of the exported tarball!
+
+        filter = get_tar_filter(args.tarfile, decompress=False) if args.tar_filter == "auto" else args.tar_filter
+
+        tarstream = dash_open(args.tarfile, "wb")
+        tarstream_close = args.tarfile != "-"
+
+        with create_filter_process(filter, stream=tarstream, stream_close=tarstream_close, inbound=False) as _stream:
+            self._export_tar(args, archive, _stream)
+
+        return self.exit_code
+
+    def _export_tar(self, args, archive, tarstream):
+        matcher = self.build_matcher(args.patterns, args.paths)
+
+        progress = args.progress
+        output_list = args.output_list
+        strip_components = args.strip_components
+        hlm = HardLinkManager(id_type=bytes, info_type=str)  # hlid -> path
+
+        filter = self.build_filter(matcher, strip_components)
+
+        # The | (pipe) symbol instructs tarfile to use a streaming mode of operation
+        # where it never seeks on the passed fileobj.
+        tar_format = dict(GNU=tarfile.GNU_FORMAT, PAX=tarfile.PAX_FORMAT, BORG=tarfile.PAX_FORMAT)[args.tar_format]
+        tar = tarfile.open(fileobj=tarstream, mode="w|", format=tar_format)
+
+        if progress:
+            pi = ProgressIndicatorPercent(msg="%5.1f%% Processing: %s", step=0.1, msgid="extract")
+            pi.output("Calculating size")
+            extracted_size = sum(item.get_size() for item in archive.iter_items(filter))
+            pi.total = extracted_size
+        else:
+            pi = None
+
+        def item_content_stream(item):
+            """
+            Return a file-like object that reads from the chunks of *item*.
+            """
+            chunk_iterator = archive.pipeline.fetch_many([chunk_id for chunk_id, _ in item.chunks], is_preloaded=True)
+            if pi:
+                info = [remove_surrogates(item.path)]
+                return ChunkIteratorFileWrapper(
+                    chunk_iterator, lambda read_bytes: pi.show(increase=len(read_bytes), info=info)
+                )
+            else:
+                return ChunkIteratorFileWrapper(chunk_iterator)
+
+        def item_to_tarinfo(item, original_path):
+            """
+            Transform a Borg *item* into a tarfile.TarInfo object.
+
+            Return a tuple (tarinfo, stream), where stream may be a file-like object that represents
+            the file contents, if any, and is None otherwise. When *tarinfo* is None, the *item*
+            cannot be represented as a TarInfo object and should be skipped.
+            """
+            stream = None
+            tarinfo = tarfile.TarInfo()
+            tarinfo.name = item.path
+            tarinfo.mtime = item.mtime / 1e9
+            tarinfo.mode = stat.S_IMODE(item.mode)
+            tarinfo.uid = item.uid
+            tarinfo.gid = item.gid
+            tarinfo.uname = item.get("user", "")
+            tarinfo.gname = item.get("group", "")
+            # The linkname in tar has 2 uses:
+            # for symlinks it means the destination, while for hardlinks it refers to the file.
+            # Since hardlinks in tar have a different type code (LNKTYPE) the format might
+            # support hardlinking arbitrary objects (including symlinks and directories), but
+            # whether implementations actually support that is a whole different question...
+            tarinfo.linkname = ""
+
+            modebits = stat.S_IFMT(item.mode)
+            if modebits == stat.S_IFREG:
+                tarinfo.type = tarfile.REGTYPE
+                if "hlid" in item:
+                    linkname = hlm.retrieve(id=item.hlid)
+                    if linkname is not None:
+                        # the first hardlink was already added to the archive, add a tar-hardlink reference to it.
+                        tarinfo.type = tarfile.LNKTYPE
+                        tarinfo.linkname = linkname
+                    else:
+                        tarinfo.size = item.get_size()
+                        stream = item_content_stream(item)
+                        hlm.remember(id=item.hlid, info=item.path)
+                else:
+                    tarinfo.size = item.get_size()
+                    stream = item_content_stream(item)
+            elif modebits == stat.S_IFDIR:
+                tarinfo.type = tarfile.DIRTYPE
+            elif modebits == stat.S_IFLNK:
+                tarinfo.type = tarfile.SYMTYPE
+                tarinfo.linkname = item.source
+            elif modebits == stat.S_IFBLK:
+                tarinfo.type = tarfile.BLKTYPE
+                tarinfo.devmajor = os.major(item.rdev)
+                tarinfo.devminor = os.minor(item.rdev)
+            elif modebits == stat.S_IFCHR:
+                tarinfo.type = tarfile.CHRTYPE
+                tarinfo.devmajor = os.major(item.rdev)
+                tarinfo.devminor = os.minor(item.rdev)
+            elif modebits == stat.S_IFIFO:
+                tarinfo.type = tarfile.FIFOTYPE
+            else:
+                self.print_warning(
+                    "%s: unsupported file type %o for tar export", remove_surrogates(item.path), modebits
+                )
+                return None, stream
+            return tarinfo, stream
+
+        def item_to_paxheaders(format, item):
+            """
+            Transform (parts of) a Borg *item* into a pax_headers dict.
+            """
+            # PAX format
+            # ----------
+            # When using the PAX (POSIX) format, we can support some things that aren't possible
+            # with classic tar formats, including GNU tar, such as:
+            # - atime, ctime (DONE)
+            # - possibly Linux capabilities, security.* xattrs (TODO)
+            # - various additions supported by GNU tar in POSIX mode (TODO)
+            #
+            # BORG format
+            # -----------
+            # This is based on PAX, but additionally adds BORG.* pax headers.
+            # Additionally to the standard tar / PAX metadata and data, it transfers
+            # ALL borg item metadata in a BORG specific way.
+            #
+            ph = {}
+            # note: for mtime this is a bit redundant as it is already done by tarfile module,
+            #       but we just do it in our way to be consistent for sure.
+            for name in "atime", "ctime", "mtime":
+                if hasattr(item, name):
+                    ns = getattr(item, name)
+                    ph[name] = str(ns / 1e9)
+            if format == "BORG":  # BORG format additions
+                ph["BORG.item.version"] = "1"
+                # BORG.item.meta - just serialize all metadata we have:
+                meta_bin = msgpack.packb(item.as_dict())
+                meta_text = base64.b64encode(meta_bin).decode()
+                ph["BORG.item.meta"] = meta_text
+            return ph
+
+        for item in archive.iter_items(filter, preload=True):
+            orig_path = item.path
+            if strip_components:
+                item.path = os.sep.join(orig_path.split(os.sep)[strip_components:])
+            tarinfo, stream = item_to_tarinfo(item, orig_path)
+            if tarinfo:
+                if args.tar_format in ("BORG", "PAX"):
+                    tarinfo.pax_headers = item_to_paxheaders(args.tar_format, item)
+                if output_list:
+                    logging.getLogger("borg.output.list").info(remove_surrogates(orig_path))
+                tar.addfile(tarinfo, stream)
+
+        if pi:
+            pi.finish()
+
+        # This does not close the fileobj (tarstream) we passed to it -- a side effect of the | mode.
+        tar.close()
+
+        for pattern in matcher.get_unmatched_include_patterns():
+            self.print_warning("Include pattern '%s' never matched.", pattern)
+        return self.exit_code
+
+    @with_repository(cache=True, exclusive=True, compatibility=(Manifest.Operation.WRITE,))
+    def do_import_tar(self, args, repository, manifest, key, cache):
+        """Create a backup archive from a tarball"""
+        self.output_filter = args.output_filter
+        self.output_list = args.output_list
+
+        filter = get_tar_filter(args.tarfile, decompress=True) if args.tar_filter == "auto" else args.tar_filter
+
+        tarstream = dash_open(args.tarfile, "rb")
+        tarstream_close = args.tarfile != "-"
+
+        with create_filter_process(filter, stream=tarstream, stream_close=tarstream_close, inbound=True) as _stream:
+            self._import_tar(args, repository, manifest, key, cache, _stream)
+
+        return self.exit_code
+
+    def _import_tar(self, args, repository, manifest, key, cache, tarstream):
+        t0 = datetime.utcnow()
+        t0_monotonic = time.monotonic()
+
+        archive = Archive(
+            repository,
+            key,
+            manifest,
+            args.name,
+            cache=cache,
+            create=True,
+            checkpoint_interval=args.checkpoint_interval,
+            progress=args.progress,
+            chunker_params=args.chunker_params,
+            start=t0,
+            start_monotonic=t0_monotonic,
+            log_json=args.log_json,
+        )
+        cp = ChunksProcessor(
+            cache=cache,
+            key=key,
+            add_item=archive.add_item,
+            write_checkpoint=archive.write_checkpoint,
+            checkpoint_interval=args.checkpoint_interval,
+            rechunkify=False,
+        )
+        tfo = TarfileObjectProcessors(
+            cache=cache,
+            key=key,
+            process_file_chunks=cp.process_file_chunks,
+            add_item=archive.add_item,
+            chunker_params=args.chunker_params,
+            show_progress=args.progress,
+            log_json=args.log_json,
+            iec=args.iec,
+            file_status_printer=self.print_file_status,
+        )
+
+        tar = tarfile.open(fileobj=tarstream, mode="r|")
+
+        while True:
+            tarinfo = tar.next()
+            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
+            elif tarinfo.isdir():
+                status = tfo.process_dir(tarinfo=tarinfo, status="d", type=stat.S_IFDIR)
+            elif tarinfo.issym():
+                status = tfo.process_symlink(tarinfo=tarinfo, status="s", type=stat.S_IFLNK)
+            elif tarinfo.islnk():
+                # tar uses a hardlink model like: the first instance of a hardlink is stored as a regular file,
+                # later instances are special entries referencing back to the first instance.
+                status = tfo.process_hardlink(tarinfo=tarinfo, status="h", type=stat.S_IFREG)
+            elif tarinfo.isblk():
+                status = tfo.process_dev(tarinfo=tarinfo, status="b", type=stat.S_IFBLK)
+            elif tarinfo.ischr():
+                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)
+            else:
+                status = "E"
+                self.print_warning("%s: Unsupported tarinfo type %s", tarinfo.name, tarinfo.type)
+            self.print_file_status(status, tarinfo.name)
+
+        # This does not close the fileobj (tarstream) we passed to it -- a side effect of the | mode.
+        tar.close()
+
+        if args.progress:
+            archive.stats.show_progress(final=True)
+        archive.stats += tfo.stats
+        archive.save(comment=args.comment, timestamp=args.timestamp)
+        args.stats |= args.json
+        if args.stats:
+            if args.json:
+                json_print(basic_json_data(archive.manifest, cache=archive.cache, extra={"archive": archive}))
+            else:
+                log_multi(str(archive), str(archive.stats), logger=logging.getLogger("borg.output.stats"))
+
+    def build_parser_tar(self, subparsers, common_parser, mid_common_parser):
+
+        from .common import process_epilog
+
+        export_tar_epilog = process_epilog(
+            """
+        This command creates a tarball from an archive.
+
+        When giving '-' as the output FILE, Borg will write a tar stream to standard output.
+
+        By default (``--tar-filter=auto``) Borg will detect whether the FILE should be compressed
+        based on its file extension and pipe the tarball through an appropriate filter
+        before writing it to FILE:
+
+        - .tar.gz or .tgz: gzip
+        - .tar.bz2 or .tbz: bzip2
+        - .tar.xz or .txz: xz
+        - .tar.zstd: zstd
+        - .tar.lz4: lz4
+
+        Alternatively, a ``--tar-filter`` program may be explicitly specified. It should
+        read the uncompressed tar stream from stdin and write a compressed/filtered
+        tar stream to stdout.
+
+        Depending on the ``-tar-format`` option, these formats are created:
+
+        +--------------+---------------------------+----------------------------+
+        | --tar-format | Specification             | Metadata                   |
+        +--------------+---------------------------+----------------------------+
+        | BORG         | BORG specific, like PAX   | all as supported by borg   |
+        +--------------+---------------------------+----------------------------+
+        | PAX          | POSIX.1-2001 (pax) format | GNU + atime/ctime/mtime ns |
+        +--------------+---------------------------+----------------------------+
+        | GNU          | GNU tar format            | mtime s, no atime/ctime,   |
+        |              |                           | no ACLs/xattrs/bsdflags    |
+        +--------------+---------------------------+----------------------------+
+
+        A ``--sparse`` option (as found in borg extract) is not supported.
+
+        By default the entire archive is extracted but a subset of files and directories
+        can be selected by passing a list of ``PATHs`` as arguments.
+        The file selection can further be restricted by using the ``--exclude`` option.
+
+        For more help on include/exclude patterns, see the :ref:`borg_patterns` command output.
+
+        ``--progress`` can be slower than no progress display, since it makes one additional
+        pass over the archive metadata.
+        """
+        )
+        subparser = subparsers.add_parser(
+            "export-tar",
+            parents=[common_parser],
+            add_help=False,
+            description=self.do_export_tar.__doc__,
+            epilog=export_tar_epilog,
+            formatter_class=argparse.RawDescriptionHelpFormatter,
+            help="create tarball from archive",
+        )
+        subparser.set_defaults(func=self.do_export_tar)
+        subparser.add_argument(
+            "--tar-filter", dest="tar_filter", default="auto", help="filter program to pipe data through"
+        )
+        subparser.add_argument(
+            "--list", dest="output_list", action="store_true", help="output verbose list of items (files, dirs, ...)"
+        )
+        subparser.add_argument(
+            "--tar-format",
+            metavar="FMT",
+            dest="tar_format",
+            default="GNU",
+            choices=("BORG", "PAX", "GNU"),
+            help="select tar format: BORG, PAX or GNU",
+        )
+        subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name")
+        subparser.add_argument("tarfile", metavar="FILE", help='output tar file. "-" to write to stdout instead.')
+        subparser.add_argument(
+            "paths", metavar="PATH", nargs="*", type=str, help="paths to extract; patterns are supported"
+        )
+        define_exclusion_group(subparser, strip_components=True)
+
+        import_tar_epilog = process_epilog(
+            """
+        This command creates a backup archive from a tarball.
+
+        When giving '-' as path, Borg will read a tar stream from standard input.
+
+        By default (--tar-filter=auto) Borg will detect whether the file is compressed
+        based on its file extension and pipe the file through an appropriate filter:
+
+        - .tar.gz or .tgz: gzip -d
+        - .tar.bz2 or .tbz: bzip2 -d
+        - .tar.xz or .txz: xz -d
+        - .tar.zstd: zstd -d
+        - .tar.lz4: lz4 -d
+
+        Alternatively, a --tar-filter program may be explicitly specified. It should
+        read compressed data from stdin and output an uncompressed tar stream on
+        stdout.
+
+        Most documentation of borg create applies. Note that this command does not
+        support excluding files.
+
+        A ``--sparse`` option (as found in borg create) is not supported.
+
+        About tar formats and metadata conservation or loss, please see ``borg export-tar``.
+
+        import-tar reads these tar formats:
+
+        - BORG: borg specific (PAX-based)
+        - PAX: POSIX.1-2001
+        - GNU: GNU tar
+        - POSIX.1-1988 (ustar)
+        - UNIX V7 tar
+        - SunOS tar with extended attributes
+
+        """
+        )
+        subparser = subparsers.add_parser(
+            "import-tar",
+            parents=[common_parser],
+            add_help=False,
+            description=self.do_import_tar.__doc__,
+            epilog=import_tar_epilog,
+            formatter_class=argparse.RawDescriptionHelpFormatter,
+            help=self.do_import_tar.__doc__,
+        )
+        subparser.set_defaults(func=self.do_import_tar)
+        subparser.add_argument(
+            "--tar-filter",
+            dest="tar_filter",
+            default="auto",
+            action=Highlander,
+            help="filter program to pipe data through",
+        )
+        subparser.add_argument(
+            "-s",
+            "--stats",
+            dest="stats",
+            action="store_true",
+            default=False,
+            help="print statistics for the created archive",
+        )
+        subparser.add_argument(
+            "--list",
+            dest="output_list",
+            action="store_true",
+            default=False,
+            help="output verbose list of items (files, dirs, ...)",
+        )
+        subparser.add_argument(
+            "--filter",
+            dest="output_filter",
+            metavar="STATUSCHARS",
+            action=Highlander,
+            help="only display items with the given status characters",
+        )
+        subparser.add_argument("--json", action="store_true", help="output stats as JSON (implies --stats)")
+
+        archive_group = subparser.add_argument_group("Archive options")
+        archive_group.add_argument(
+            "--comment", dest="comment", metavar="COMMENT", default="", help="add a comment text to the archive"
+        )
+        archive_group.add_argument(
+            "--timestamp",
+            dest="timestamp",
+            type=timestamp,
+            default=None,
+            metavar="TIMESTAMP",
+            help="manually specify the archive creation date/time (UTC, yyyy-mm-ddThh:mm:ss format). "
+            "alternatively, give a reference file/directory.",
+        )
+        archive_group.add_argument(
+            "-c",
+            "--checkpoint-interval",
+            dest="checkpoint_interval",
+            type=int,
+            default=1800,
+            metavar="SECONDS",
+            help="write checkpoint every SECONDS seconds (Default: 1800)",
+        )
+        archive_group.add_argument(
+            "--chunker-params",
+            dest="chunker_params",
+            action=Highlander,
+            type=ChunkerParams,
+            default=CHUNKER_PARAMS,
+            metavar="PARAMS",
+            help="specify the chunker parameters (ALGO, CHUNK_MIN_EXP, CHUNK_MAX_EXP, "
+            "HASH_MASK_BITS, HASH_WINDOW_SIZE). default: %s,%d,%d,%d,%d" % CHUNKER_PARAMS,
+        )
+        archive_group.add_argument(
+            "-C",
+            "--compression",
+            metavar="COMPRESSION",
+            dest="compression",
+            type=CompressionSpec,
+            default=CompressionSpec("lz4"),
+            help="select compression algorithm, see the output of the " '"borg help compression" command for details.',
+        )
+
+        subparser.add_argument("name", metavar="NAME", type=NameSpec, help="specify the archive name")
+        subparser.add_argument("tarfile", metavar="TARFILE", help='input tar file. "-" to read from stdin instead.')