When extracting a contiguous regular member from an uncompressed local
regular archive, copy its data directly to the output file with
copy_file_range().  This avoids routing data through userspace and can
share extents on filesystems that support it.

Use an explicit source offset so archive read-ahead remains undisturbed,
and advance tar's logical input position only after the complete copy.
Fall back to buffered I/O when no bytes were copied and the syscall or
filesystem does not support the operation.  Keep buffered extraction for
compressed, remote, multi-volume and pipe inputs, sparse members, command
output, non-regular stdout, and when checkpoint actions must run per record.

Import gnulib's copy-file-range module, document the optimization, and
add coverage for aligned multi-record members, selected-member offsets,
overwrite truncation, redirected stdout, checkpoints, pipes, and compressed
archives.

Signed-off-by: Daan De Meyer <[email protected]>
---
 NEWS               |  7 +++++
 doc/tar.texi       | 10 +++++++
 gnulib.modules     |  1 +
 src/buffer.c       | 71 ++++++++++++++++++++++++++++++++++++++++++++++
 src/common.h       |  9 ++++++
 src/extract.c      | 27 +++++++++++++++++-
 tests/Makefile.am  |  1 +
 tests/extrac35.at  | 71 ++++++++++++++++++++++++++++++++++++++++++++++
 tests/testsuite.at |  1 +
 9 files changed, 197 insertions(+), 1 deletion(-)
 create mode 100644 tests/extrac35.at

diff --git a/NEWS b/NEWS
index 2dbdc243..5a3cff8f 100644
--- a/NEWS
+++ b/NEWS
@@ -13,6 +13,13 @@ in the uncompressed archive stream.  SIZE must be a power of 
two and a
 multiple of 512, and cannot exceed 1 GiB.  This can make extraction via
 reflinks or copy_file_range(2) more efficient.
 
+* Faster extraction from regular archive files
+
+When extracting a contiguous regular file from an uncompressed local archive,
+tar now uses copy_file_range(2) where possible.  This avoids copying file data
+through userspace and can share extents on file systems that support reflinks.
+Tar falls back to buffered I/O when the fast path is unavailable.
+
 * New options: --set-mtime-command and --set-mtime-format
 
 Both options are valid when archiving files.
diff --git a/doc/tar.texi b/doc/tar.texi
index 91632552..75eb9275 100644
--- a/doc/tar.texi
+++ b/doc/tar.texi
@@ -1708,6 +1708,16 @@ You can extract a file to standard output by combining 
the above options
 with the @option{--to-stdout} (@option{-O}) option (@pxref{Writing to Standard
 Output}).
 
+@cindex copy_file_range
+When extracting a contiguous regular file from an uncompressed archive that
+is itself a local regular file, @GNUTAR{} attempts to copy the file data
+directly with @code{copy_file_range}.  On file systems that support it, this
+can share the underlying extents instead of copying their contents.  If the
+operation is unavailable, @GNUTAR{} transparently falls back to buffered I/O.
+This also applies to @option{--to-stdout} when standard output is redirected
+to a regular file.  Compressed archives, pipes, sparse members, checkpoint
+actions, and extraction to a command use buffered I/O.
+
 If you give the @option{--verbose} option, then @option{--extract}
 will print the names of the archive members as it extracts them.
 
diff --git a/gnulib.modules b/gnulib.modules
index 0dc024fe..52555475 100644
--- a/gnulib.modules
+++ b/gnulib.modules
@@ -32,6 +32,7 @@ c32tolower
 c32toupper
 closeout
 configmake
+copy-file-range
 dirname
 dup2
 errno-h
diff --git a/src/buffer.c b/src/buffer.c
index a48547d6..e8ebcec7 100644
--- a/src/buffer.c
+++ b/src/buffer.c
@@ -593,6 +593,77 @@ current_block_ordinal (void)
   return record_start_block + (current_block - record_start);
 }
 
+/* Copy SIZE bytes starting at the current logical archive position directly
+   to FD.  This can share extents when the input and output file systems
+   support it.  Leave the logical archive position unchanged; the caller must
+   advance it after a successful copy.
+
+   Return ARCHIVE_COPY_UNAVAILABLE if a buffered copy should be used instead,
+   ARCHIVE_COPY_OK on success, ARCHIVE_COPY_TRUNCATED if the source ends after
+   a partial copy, and ARCHIVE_COPY_ERROR if an error occurred after copying
+   began or cannot usefully be retried with buffered I/O.  */
+enum archive_copy_status
+archive_copy_file_range (int fd, off_t size)
+{
+  static bool unsupported;
+  struct stat st;
+  off_t input_offset;
+  off_t remaining = size;
+  bool copied = false;
+
+  if (size <= 0
+      || unsupported
+      || !seekable_archive
+      || checkpoint_option
+      || multi_volume_option
+      || use_compress_program_option
+      || _isrmt (archive)
+      || !S_ISREG (archive_stat.st_mode)
+      || fstat (fd, &st) != 0
+      || !S_ISREG (st.st_mode)
+      || ckd_mul (&input_offset, current_block_ordinal (), BLOCKSIZE)
+      || ckd_add (&input_offset, input_offset, start_offset))
+    return ARCHIVE_COPY_UNAVAILABLE;
+
+  while (remaining != 0)
+    {
+      size_t chunk = min ((uintmax_t) remaining,
+                          min (SSIZE_MAX, SIZE_MAX));
+      ssize_t n = copy_file_range (archive, &input_offset, fd, NULL,
+                                   chunk, 0);
+
+      if (0 < n)
+        {
+          copied = true;
+          remaining -= n;
+          continue;
+        }
+      if (n < 0 && errno == EINTR)
+        continue;
+
+      if (!copied
+          && (n == 0
+              || errno == ENOSYS
+              || errno == EXDEV
+              || errno == EINVAL
+              || errno == EBADF
+              || errno == EPERM
+              || errno == ENOTSUP
+              || (EOPNOTSUPP != ENOTSUP && errno == EOPNOTSUPP)))
+        {
+          if (n < 0 && errno == ENOSYS)
+            unsupported = true;
+          return ARCHIVE_COPY_UNAVAILABLE;
+        }
+
+      if (n == 0)
+        return ARCHIVE_COPY_TRUNCATED;
+      return ARCHIVE_COPY_ERROR;
+    }
+
+  return ARCHIVE_COPY_OK;
+}
+
 /* If the EOF flag is set, reset it, as well as current_block, etc.  */
 void
 reset_eof (void)
diff --git a/src/common.h b/src/common.h
index f4d31be8..e78afc91 100644
--- a/src/common.h
+++ b/src/common.h
@@ -464,6 +464,15 @@ char *drop_volume_label_suffix (const char *label)
 
 idx_t available_space_after (union block *pointer);
 off_t current_block_ordinal (void);
+
+enum archive_copy_status
+  {
+    ARCHIVE_COPY_UNAVAILABLE,
+    ARCHIVE_COPY_OK,
+    ARCHIVE_COPY_TRUNCATED,
+    ARCHIVE_COPY_ERROR
+  };
+enum archive_copy_status archive_copy_file_range (int fd, off_t size);
 void close_archive (void);
 void closeout_volume_number (void);
 double compute_duration_ns (void);
diff --git a/src/extract.c b/src/extract.c
index 5cc3ccd6..5fa181f3 100644
--- a/src/extract.c
+++ b/src/extract.c
@@ -1369,7 +1369,31 @@ extract_file (char *file_name, char typeflag)
   if (current_stat_info.is_sparse)
     sparse_extract_file (fd, &current_stat_info, &size);
   else
-    for (size = current_stat_info.stat.st_size; size > 0; )
+    {
+      size = current_stat_info.stat.st_size;
+
+      /* For a local, uncompressed regular archive, let the kernel copy the
+         member directly into a regular output file.  On file systems that
+         support it this can share the underlying extents.  */
+      enum archive_copy_status copy_status
+        = archive_copy_file_range (fd, size);
+
+      if (copy_status == ARCHIVE_COPY_OK)
+        {
+          skim_file (size, false);
+          size = 0;
+        }
+      else if (copy_status == ARCHIVE_COPY_TRUNCATED)
+        {
+          paxerror (0, _("Unexpected EOF in archive"));
+        }
+      else if (copy_status == ARCHIVE_COPY_ERROR)
+        {
+          paxerror (errno, _("%s: copy_file_range failed"),
+                    quote (file_name));
+        }
+
+      for (; copy_status == ARCHIVE_COPY_UNAVAILABLE && size > 0; )
       {
        mv_size_left (size);
 
@@ -1401,6 +1425,7 @@ extract_file (char *file_name, char typeflag)
            break;
          }
       }
+    }
 
   skim_file (size, false);
   current_stat_info.skipped = true;
diff --git a/tests/Makefile.am b/tests/Makefile.am
index b06abbfa..1d7578e1 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -145,6 +145,7 @@ TESTSUITE_AT = \
  extrac32.at\
  extrac33.at\
  extrac34.at\
+ extrac35.at\
  filerem01.at\
  filerem02.at\
  filerem03.at\
diff --git a/tests/extrac35.at b/tests/extrac35.at
new file mode 100644
index 00000000..e8b22c90
--- /dev/null
+++ b/tests/extrac35.at
@@ -0,0 +1,71 @@
+# Test copy_file_range extraction.  -*- autotest -*-
+
+# Copyright 2026 Free Software Foundation, Inc.
+
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3, or (at your option)
+# any later version.
+
+AT_SETUP([copy_file_range extraction])
+AT_KEYWORDS([extract extrac35 copy_file_range reflink align])
+
+AT_CHECK([
+mkdir input local selected checkpoint pipe
+genfile --file=input/large --length=70001
+genfile --file=input/block --length=131072
+genfile --file=input/small --length=513
+
+# A one-block record makes each successful direct copy cross many input
+# records.  Alignment also exercises the PAX padding used to make extent
+# sharing effective.
+tar -C input --align=4096 -b1 -cf archive.tar large block small || exit 1
+tar -C local -b1 -xf archive.tar || exit 1
+cmp input/large local/large || exit 1
+cmp input/block local/block || exit 1
+cmp input/small local/small || exit 1
+
+# The source offset must remain correct after skipping an earlier member, and
+# an existing destination must be truncated before the direct copy.
+tar -C selected -b1 -xf archive.tar block || exit 1
+cmp input/block selected/block || exit 1
+genfile --file=local/large --length=140000 || exit 1
+tar -C local -b1 --overwrite -xf archive.tar large || exit 1
+cmp input/large local/large || exit 1
+
+# Checkpoints are tied to input records, so they disable direct copying.
+tar -C checkpoint -b1 --checkpoint=1 -xf archive.tar 2>checkpoints || exit 1
+test $(wc -l < checkpoints) -gt 100 || exit 1
+cmp input/large checkpoint/large || exit 1
+cmp input/block checkpoint/block || exit 1
+cmp input/small checkpoint/small || exit 1
+
+# Standard output is eligible when redirected to a regular file.  A pipe is
+# rejected by the destination check and uses buffered I/O.
+cat input/large input/small > expected-stdout || exit 1
+tar -b1 -xOf archive.tar large small > stdout-file || exit 1
+cmp expected-stdout stdout-file || exit 1
+tar -b1 -xOf archive.tar block | cmp - input/block || exit 1
+
+# A pipe is not a copy_file_range source and must use buffered I/O.
+cat archive.tar | tar -C pipe -b1 -B -xf - || exit 1
+cmp input/large pipe/large || exit 1
+cmp input/block pipe/block || exit 1
+cmp input/small pipe/small
+], [0], [], [])
+
+AT_CLEANUP
+
+AT_SETUP([copy_file_range compressed fallback])
+AT_KEYWORDS([extract extrac35 copy_file_range gzip])
+
+AT_CHECK([
+AT_GZIP_PREREQ
+mkdir input output
+genfile --file=input/file --length=70001
+tar -C input -czf archive.tar.gz file || exit 1
+tar -C output -xzf archive.tar.gz || exit 1
+cmp input/file output/file
+], [0], [], [])
+
+AT_CLEANUP
diff --git a/tests/testsuite.at b/tests/testsuite.at
index f046adb6..460fa908 100644
--- a/tests/testsuite.at
+++ b/tests/testsuite.at
@@ -361,6 +361,7 @@ m4_include([extrac31.at])
 m4_include([extrac32.at])
 m4_include([extrac33.at])
 m4_include([extrac34.at])
+m4_include([extrac35.at])
 
 m4_include([backup01.at])
 
-- 
2.54.0


Reply via email to