Skip to content

export-tar: support sparse files (GNU sparse format 1.0) - #10128

Merged
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:export-tar-sparse-2562
Aug 18, 2026
Merged

export-tar: support sparse files (GNU sparse format 1.0)#10128
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:export-tar-sparse-2562

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 16, 2026

Copy link
Copy Markdown
Member

Fixes #2562.

Add a --sparse option to export-tar (BORG and PAX tar formats only): files whose content contains runs of all-zero chunks are written as sparse tar members in GNU sparse format 1.0 (what GNU tar creates in POSIX mode), storing only a hole map and the non-hole data.

How

The issue's original blocker was that the sparse map must precede the file data in the tar stream. That is solved by computing the hole map from the item's chunk list metadata alone, before fetching any data: an all-zero chunk is detected by comparing its id against the (memoized) id of an all-zero chunk of the same size, reusing the zeros-shortcut heuristic of DownloadPipeline.fetch_many (extracted into a shared zero_chunk_flags()). The memo is additionally warmed up for power-of-two chunk sizes and each file's last chunk size, so single (non-repeated) zero chunks — the only chunk of an all-zero file, or a trailing hole's tail — are detected as well. The zero chunks of a sparse member are never fetched from the repository at all.

Python's tarfile reads GNU sparse members (import-tar has always consumed such tarballs correctly) but cannot write them, so the writing side crafts the pax records and the map itself and streams map + data segments through tarfile.addfile():

  • pax record order matters for in-order readers: path (mangled GNUSparseFile.0/<name>, like GNU tar) first, GNU.sparse.name / GNU.sparse.realsize last, so the real name/size win.
  • no pax size record may be written for a sparse member (readers would desync when recalculating the next-header offset from it); a stored (non-hole) size beyond the 12-digit octal ustar field limit (8 GiB) is base-256 encoded into that field instead (the GNU/star big-number encoding, accepted by GNU tar, libarchive and python's tarfile) — so both stored data and logical size (GNU.sparse.realsize) are effectively unlimited.
  • members where the map would cost more than the holes save also stay dense; without --sparse, output is byte-identical to before.
  • --sparse with --tar-format=GNU errors out (old-GNU sparse headers are not PAX-based and deprecated).

Hole detection is chunk-granular (like extract --sparse) and does not depend on the original file having been sparse on disk.

Interop

Verified against GNU tar 1.35 (byte-level comparison of the member layout gtar produces, including the terminal (realsize, 0) map entry convention), bsdtar/libarchive 3.7.4, python tarfile in both r: and r| modes, and borg import-tar round trips (PAX and BORG formats).

Tests

Unit tests for the map builders and zero_chunk_flags; integration tests parametrized over a fixed chunker and a small-target fastcdc chunker (16 KiB target / 64 KiB max, so the small test files' sparse ranges yield multiple pure repeated all-zero chunks, as with the default chunker on real files); round trips (PAX/BORG × both chunkers), GNU tar extraction interop (incl. on-disk sparseness check on Linux), hardlinks, --strip-components, all-hole/trailing-hole/empty/unaligned edge cases, not-worthwhile fallback, and the GNU-format error.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.52239% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.94%. Comparing base (91b030e) to head (9547a53).
⚠️ Report is 33 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/archiver/tar_cmds.py 95.12% 2 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #10128      +/-   ##
==========================================
- Coverage   87.05%   86.94%   -0.11%     
==========================================
  Files         101      101              
  Lines       17848    17984     +136     
  Branches     2705     2737      +32     
==========================================
+ Hits        15537    15637     +100     
- Misses       1609     1638      +29     
- Partials      702      709       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann
ThomasWaldmann force-pushed the export-tar-sparse-2562 branch from 40033ec to 3b04cd7 Compare August 18, 2026 11:33
Comment thread docs/usage/tar.rst Outdated
Add a --sparse option to export-tar (BORG and PAX tar formats only):
files whose content contains runs of all-zero chunks are written as
sparse tar members in GNU sparse format 1.0 (what GNU tar creates in
POSIX mode), storing only a hole map and the non-hole data.

The hole map is computed from the item's chunk list metadata alone,
before any data is fetched: an all-zero chunk is detected by comparing
its id against the (memoized) id of an all-zero chunk of the same size,
reusing the zeros shortcut heuristic of DownloadPipeline.fetch_many
(now extracted into a shared zero_chunk_flags function). Additionally,
the memo is warmed up for power-of-two chunk sizes and each file's last
chunk size, so single (non-repeated) zero chunks - like the only chunk
of an all-zero file or a trailing hole's tail - get detected, too.
The zero chunks of a sparse member are never fetched from the
repository at all.

Notes:
- python's tarfile module reads GNU sparse members (import-tar has
  always consumed such tarballs correctly), but cannot write them, so
  the writing side crafts the pax records and the map itself and
  streams map + data segments via tarfile.addfile().
- holes are shrunk to whole 512-byte tar blocks within a zero run
  (GNU tar's sparse reader processes data segments block-wise and
  desyncs on unaligned segments); the zero bytes shaved off the hole
  edges are emitted as literal zeros, still without fetching the
  all-zero chunks. Zero runs shorter than a block stay dense, as do
  members where the map would cost more than the holes save.
- pax record order matters for in-order readers: path (mangled
  GNUSparseFile.0/<name>, like GNU tar) comes first, GNU.sparse.name /
  GNU.sparse.realsize come last, so the real name/size win.
- no pax size record must be written for a sparse member (readers
  would desync recalculating the next-header offset from it), so the
  stored size always lives in the ustar size field: standard octal
  while it fits (< 8 GiB), else base-256 encoded (the GNU/star
  big-number encoding, which GNU tar, libarchive and python's tarfile
  all read in any tar format) - so stored data and logical file size
  (GNU.sparse.realsize) are effectively unlimited.
- without --sparse, the output is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ThomasWaldmann
ThomasWaldmann force-pushed the export-tar-sparse-2562 branch from 3b04cd7 to 9547a53 Compare August 18, 2026 11:36
@ThomasWaldmann
ThomasWaldmann merged commit 2529cd5 into borgbackup:master Aug 18, 2026
22 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

export-tar: support sparse files

1 participant