Cheat sheets tar

tar cheat sheet: create, list, extract, and compress archives

tar packs files and directories into one archive file, keeping names, permissions, owners and timestamps, and unpacks them again. On Debian, Ubuntu, Fedora and Arch, tar is GNU tar. On macOS and FreeBSD, tar is bsdtar from libarchive. The two share the everyday flags and differ in the corners, and this sheet marks each difference it covers. Every flag below is from the GNU tar manual or the bsdtar man page. The GNU examples were run with GNU tar 1.35 and the bsdtar examples with bsdtar 3.7.4 on Linux. We attempt to test on all versions. Nothing on this page was run on macOS, so macOS-only notes say "per the bsdtar man page (not run on macOS)".

what it is and when to use it

  • Archive: one file holding many files plus their metadata. A plain .tar isn’t compressed.
  • Compressed archive: the same tar stream run through gzip, bzip2, xz or zstd, giving .tar.gz (or .tgz), .tar.bz2, .tar.xz or .tar.zst.
  • Member: one file, directory or link inside the archive, stored under the path you gave when you created it.
  • Mode: every tar command does exactly one of -c (create), -t (list), -x (extract), -r (append), -u (update), or in GNU tar -d (compare) and --delete. -f names the archive file, and -f - means stdin or stdout.

Reach for it to back up or ship a directory as one file, to copy a tree with its permissions intact (including across ssh), and to unpack the source tarballs and release downloads that still come as .tar.gz or .tar.xz.

install and check the version

GNU tar is the tar package on Debian, Ubuntu, Fedora and Arch, and macOS ships bsdtar as /usr/bin/tar. To add the other one, sudo apt install libarchive-tools installs bsdtar on Debian or Ubuntu, and brew install gnu-tar installs GNU tar on macOS as gtar.

CommandWhat it does
tar --version | head -1
bsdtar --version
output
tar (GNU tar) 1.35
bsdtar 3.7.4 - libarchive 3.7.4 zlib/1.3.1 liblzma/5.8.1 bz2lib/1.0.8 liblz4/1.10.0 libzstd/1.5.7
Which tar is this? GNU tar prints "tar (GNU tar)", bsdtar prints "bsdtar"

create, list, extract

CommandWhat it does
tar -cf proj.tar proj
REPLACES ARCHIVE: create proj.tar from the directory proj (an existing proj.tar is overwritten)
tar -tf proj.tar
List the member names
tar -tvf proj.tar
output
drwxr-xr-x box/box           0 2026-10-25 09:00 proj/
drwxr-xr-x box/box           0 2026-10-25 09:00 proj/logs/
-rw-r--r-- box/box           9 2026-10-25 09:00 proj/logs/app.log
-rwxr-xr-x box/box          19 2026-10-25 09:00 proj/run.sh
List with permissions, owner, size and date (output trimmed to four lines)

bsdtar’s -tvf prints an ls -l style line instead, such as -rw-r--r-- 0 box box 7 Oct 25 09:00 proj/README.md.

CommandWhat it does
tar -xf proj.tar
OVERWRITES: extract everything into the current directory
tar -xkf proj.tar
output
tar: proj/README.md: Cannot open: File exists
tar: Exiting with failure status due to previous errors
Extract, but refuse to replace files that already exist (GNU and bsdtar)

That output is trimmed to one of the eight "File exists" lines. GNU tar exits 2 but leaves the existing files alone. bsdtar 3.7.4 kept the existing file too, silently, with exit status 0. GNU’s --skip-old-files skips them without errors.

CommandWhat it does
tar -cvf proj.tar proj
Print each name as it’s archived (verbose)

GNU tar’s verbose create prints plain names (proj/README.md). bsdtar prefixes each with a (a proj/README.md). Peeking inside a .tar.gz without unpacking is in the archives one-liners.

compression: gzip, bzip2, xz, zstd

Add one letter to compress on the way out. When extracting or listing from a file, both GNU tar and bsdtar detect the compression themselves, so tar -xf works for every row below.

CommandWhat it does
tar -czf proj.tar.gz proj
REPLACES ARCHIVE: gzip (.tar.gz or .tgz)
tar -cjf proj.tar.bz2 proj
REPLACES ARCHIVE: bzip2 (.tar.bz2)
tar -cJf proj.tar.xz proj
REPLACES ARCHIVE: xz (.tar.xz)
tar --zstd -cf proj.tar.zst proj
REPLACES ARCHIVE: zstd (GNU tar 1.31 and later, and bsdtar)
tar -caf proj.tar.zst proj
REPLACES ARCHIVE: let -a pick the compression from the suffix
tar -xf proj.tar.xz -C restore
OVERWRITES files of the same name in restore/: extract any of them, compression detected from the file
tar -I 'zstd -19 -T0' -cf proj-19.tar.zst proj
GNU: pass options to the compressor (zstd level 19, all cores)
bsdtar --zstd --options zstd:compression-level=19 -cf proj-19.tar.zst proj
bsdtar: the same compression level through --options

Without -a, the letter flag wins over the file name. tar -czf wrong.tar.zst proj still writes gzip:

output
wrong.tar.zst: gzip compressed data, from Unix, original size modulo 2^32 20480

That’s file wrong.tar.zst. With -a, the suffix wins: tar -cajf aj.tgz proj wrote gzip in both tars. With a suffix neither knows (unk.xxx), -caf wrote an uncompressed archive and -cajf wrote bzip2, in both tars. How small and how fast each compressor is depends on your data, so try them on your own files.

extract into another directory with -C

CommandWhat it does
mkdir -p ~/restore && tar -xzf proj.tar.gz -C ~/restore
OVERWRITES files of the same name under ~/restore: extract there instead of the current directory

-C doesn’t create the directory. If it’s missing, GNU tar stops with exit status 2:

output
tar: /tmp/tl-tar/no-such-dir: Cannot open: No such file or directory
tar: Error is not recoverable: exiting now

bsdtar says bsdtar: could not chdir to '/tmp/tl-tar/no-such-dir'.

CommandWhat it does
tar -czf proj.tar.gz -C /srv/app proj
REPLACES ARCHIVE: archive /srv/app/proj with relative names, without cd-ing there first

Members are stored as proj/..., not /srv/app/proj/.... Give tar an absolute path instead and both versions strip the leading /, saying so: tar: Removing leading `/' from member names from GNU, bsdtar: Removing leading '/' from member names from bsdtar.

extract one file or path

Name members exactly as tar -tf prints them.

CommandWhat it does
tar -xf proj.tar proj/src/main.c
OVERWRITES: extract one file (directories on its path are created)
tar -xf proj.tar proj/src
OVERWRITES: extract one directory and everything under it
tar -xOf proj.tar.gz proj/README.md
output
# proj
Print one member to stdout instead of writing it to disk
tar -xf proj.tar --wildcards 'proj/src/*.c'
OVERWRITES: GNU needs --wildcards for patterns when extracting

Without --wildcards, GNU tar treats the pattern as a literal name, warns Pattern matching characters used in file names, then fails with tar: proj/src/*.c: Not found in archive and exit status 2.

CommandWhat it does
bsdtar -xf proj.tar 'proj/src/*.c'
OVERWRITES: bsdtar matches patterns by default (it has no --wildcards option)

An archive made with tar -czf dot.tar.gz -C proj . stores names as ./README.md. GNU tar then fails on README.md with tar: README.md: Not found in archive, so ask for ./README.md. bsdtar accepts README.md for ./README.md.

strip the top directory with --strip-components

Many release tarballs unpack into a versioned directory such as proj-1.2/. --strip-components=N drops the first N path parts from every member name. GNU tar and bsdtar both have it.

CommandWhat it does
mkdir app && tar -xzf proj-1.2.tar.gz -C app --strip-components=1
OVERWRITES: unpack proj-1.2/* straight into app/
mkdir appsrc && tar -xzf proj-1.2.tar.gz -C appsrc --strip-components=2 proj-1.2/src
output
appsrc
appsrc/main.c
appsrc/util.c
OVERWRITES: just the src directory’s files, with proj-1.2/src/ stripped off

The bsdtar man page says members with fewer path parts than N are "silently skipped".

exclude files

The archives one-liners exclude by file pattern. These are the other forms.

CommandWhat it does
tar -cf src.tar --exclude=proj/logs --exclude='*.o' proj
REPLACES ARCHIVE: leave out one directory and every .o file
tar -cf src.tar --exclude-vcs proj
REPLACES ARCHIVE: leave out .git, .svn, .hg and other version-control directories
printf '%s\n' 'proj/logs' 'proj/build' '*.env' > excludes.txt
tar -cf src.tar -X excludes.txt proj
REPLACES ARCHIVE: read exclude patterns from a file, one per line

bsdtar has --exclude, --exclude-vcs and -X too. GNU excludes are unanchored, and * matches /. In the test, --exclude=build matched at any depth, --exclude=src/main.c also dropped proj/src/main.c, and --exclude='proj/*.c' dropped proj/src/*.c as well as proj/top.c. A leading / matched nothing. To pin a pattern to the start of the name, put --anchored before it.

Put --exclude before the directory name. Placed after it, GNU tar 1.35 creates the archive anyway, without the exclude, and exits 2:

output
tar: The following options were used after non-option arguments.  These options are positional and affect only arguments that follow them.  Please, rearrange them properly.
tar: --exclude ‘proj/logs’ has no effect
tar: Exiting with failure status due to previous errors

On extract, GNU tar 1.35 applied the exclude wherever it was placed, with no warning and exit status 0. bsdtar treats everything after the first file name as a file name. bsdtar -cf b.tar proj --exclude proj/logs printed bsdtar: --exclude: Cannot stat: No such file or directory, archived proj/logs twice and exited 1.

Leave off the trailing slash: --exclude=proj/logs/ excluded nothing in GNU tar 1.35. Use --exclude=proj/logs, or --exclude='proj/logs/*' to keep the empty directory. bsdtar 3.7.4 excluded the directory with or without the slash.

append, update, delete

These only work on an uncompressed .tar in a regular file, not on .tar.gz and the rest. The bsdtar man page also rules out anything that isn’t a regular file, such as a pipe.

CommandWhat it does
tar -rf proj.tar proj/NOTES.md
Add a file to the end of an existing archive
tar -uf proj.tar proj
Append copies of files that are newer than the archived ones

-u doesn’t replace anything. It adds a second copy, so the archive only grows:

output
-rw-r--r-- box/box           7 2026-10-25 09:00 proj/README.md
-rw-r--r-- box/box           6 2026-10-25 10:54 proj/NOTES.md
-rw-r--r-- box/box          10 2026-10-25 10:54 proj/README.md

That’s tar -tvf proj.tar | grep -E "README|NOTES" after one -r and one -u. Extracting writes each copy in turn, so the last one wins.

CommandWhat it does
cp proj.tar trimmed.tar && tar --delete -f trimmed.tar proj/logs
REWRITES ARCHIVE in place, GNU only: remove members from an archive. Work on a copy

On a compressed archive, GNU tar refuses with tar: Cannot update compressed archives and bsdtar with bsdtar: Cannot append to compressed archive. bsdtar has no --delete (bsdtar: Option --delete is not supported). For a backup that changes, making a fresh archive is simpler than -u.

pipes, stdin, and ssh

-f - reads the archive from stdin or writes it to stdout, so tar can feed another tar, a compressor or ssh without a temporary file.

CommandWhat it does
mkdir copy && tar -C proj -cf - . | tar -C copy -xpf -
OVERWRITES files in copy/: copy a tree with permissions, through a pipe
tar -czf - proj | ssh host 'mkdir -p /srv/x && tar -xzf - -C /srv/x'
OVERWRITES files under /srv/x on host: push a directory to another machine
ssh host 'tar -C /etc -czf - nginx' > nginx-etc.tar.gz
tar -tzf nginx-etc.tar.gz
REPLACES ARCHIVE: pull a remote directory into a local archive, then check it reads
find proj -name '*.c' -print0 | tar --null -cf cfiles.tar -T -
Archive only the files another command lists, safe for names with spaces or newlines (GNU and bsdtar)

GNU tar doesn’t detect compression on stdin, so cat proj.tar.gz | tar -tf - stops with tar: Archive is compressed. Use -z option. cat proj.tar.gz | tar -tzf - works. bsdtar detected gzip and zstd on stdin without a flag.

The ssh rows ran over a real ssh hop through a bastion in a lab: GNU tar 1.34 and OpenSSH 9.2p1 on Debian 12 (aarch64), on both the client and the remote side, as an unprivileged user. /tmp on the remote side stood in for /srv, and the pull was run with /etc/apt because nginx wasn’t installed. Pitfalls from that run:

  • A missing remote -C directory fails only on the remote side. The remote tar printed tar: /tmp/nope: Cannot open: No such file or directory, while the local tar exited 0 for a small tree; with a larger one it also fails, with Wrote only … bytes. That second case was reproduced on the box with a 31 MB tree and sh -c in place of ssh: tar: -: Wrote only 8192 of 10240 bytes, PIPESTATUS 2 2. The mkdir -p in the push row fixes it.
  • In bash, echo "${PIPESTATUS[@]}" straight after the pipe shows both sides' exit codes: the local tar’s, then ssh’s, which passes on the remote tar’s. For the missing directory it was 0 2.
  • Never use ssh -n (-f implies -n) when piping into ssh. ssh then sends nothing, and the remote tar stopped with gzip: stdin: unexpected end of file (PIPESTATUS 0 2).
  • Don’t force a terminal. With -tt, or with -t from an interactive shell on the pull, the remote tar refuses with Refusing to write archive contents to terminal and the local tar sees gzip: stdin: not in gzip format. In the lab, ssh -tt gave that gzip message and PIPESTATUS 2 2. The refusal itself was reproduced on the box by giving GNU tar 1.35 a terminal with script; bsdtar 3.7.4 didn’t refuse. The interactive -t case wasn’t run. From a script, plain -t only warned Pseudo-terminal will not be allocated because stdin is not a terminal. and the archive came through.
  • After a pull to a file, check it with tar -tzf file. The shell creates the file even when the remote side fails.
  • /srv and /etc/nginx normally need root on the remote side, and extracting as root brings back the archived owners and setuid bits (see the next section).
  • Anything the remote shell’s startup files print to stdout lands in the stream and corrupts it (reproduced locally, not over the ssh hop).
  • Inside double quotes, $VARIABLES expand on your machine before ssh sends the command. Single quotes, as above, send them to the remote shell.

For copying the same tree again and again, rsync only sends what changed.

permissions and ownership

What happens to owners and modes depends on who runs the extract.

  • As a normal user: files belong to you, and your umask is subtracted from each file’s mode. -p keeps the archived modes. You can’t give files to another owner.
  • As root: GNU tar and bsdtar both restore the archived owner, group and mode by default. umask is ignored, and setuid and setgid come back.

The archived file here is mode 666, owned by uid 1234. With umask 022, ls -ln showed:

CommandWhat it does
tar -xf perm.tar -C p1
output
-rw-r--r-- 1 1000 1000 2 Oct 25 09:00 shared.txt
OVERWRITES: as a normal user, owner becomes you, umask applied
tar -xpf perm.tar -C p2
output
-rw-rw-rw- 1 1000 1000 2 Oct 25 09:00 shared.txt
OVERWRITES: as a normal user, keeping the archived modes
sudo tar -xf perm.tar -C p3
output
-rw-rw-rw- 1 1234 1234 2 Oct 25 09:00 shared.txt
OVERWRITES: as root, the archived owner and mode come back
sudo tar --no-same-owner --no-same-permissions -xf perm.tar -C p4
output
-rw-r--r-- 1 0 0 2 Oct 25 09:00 shared.txt
OVERWRITES: as root, files owned by root, special bits dropped (GNU needs both flags)

--no-same-owner alone keeps setuid and setgid, so a setuid member becomes setuid-root. bsdtar -o removes those bits but kept the other mode bits (-rw-rw-rw-, owned by root).

That’s the root caveat. A member stored as -rwsr-sr-x (setuid and setgid), owned by uid 1234, came out of the test like this:

output
-rwsr-sr-x 1 1234 1234 2 Oct 25 09:00 tool
  • That’s sudo tar -xf and sudo bsdtar -xf, even with umask 077. sudo tar --no-same-owner -xf gave -rwsr-sr-x 1 0 0. Adding --no-same-permissions gave -rwxr-xr-x 1 0 0, and sudo bsdtar -xof gave the same.
  • As a normal user without -p, both tars dropped the bits. GNU tar -xpf kept them on a file you now own. bsdtar -xpf dropped them.
  • When the owner can’t be restored, GNU 1.35 doesn’t clear the bits. As a normal user, tar --same-owner -xpf printed Cannot change ownership to uid 1234, gid 1234: Operation not permitted, exited 2, and still left -rwsr-sr-x. bsdtar printed su/tool: Can't restore SUID bit and left -rwxr-xr-x.

Extract archives you didn’t make as a normal user, without -p.

CommandWhat it does
tar -cf release.tar --owner=0 --group=0 --numeric-owner proj
REPLACES ARCHIVE: store files as owned by root (uid and gid 0), whoever runs it (GNU tar)
bsdtar -cf release.tar --uid 0 --gid 0 --uname root --gname root proj
REPLACES ARCHIVE: the same with bsdtar

Extended attributes: bsdtar archives them by default. A normal user gets them back only with -p, and root gets them by default. GNU tar needs --xattrs on both create and extract. GNU read bsdtar’s xattrs with --xattrs but warned Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.user.note'. GNU also has --acls and --selinux (not run here).

sparse files

CommandWhat it does
tar -cSf disk.tar disk.img
REPLACES ARCHIVE, GNU: store the holes in sparse files instead of writing out the zeros

In the test, a 100 MB sparse file holding one 4-byte write (4 KB on disk) made a 104,867,840-byte archive with plain tar -cf and a 10,240-byte one with -S. bsdtar handled the holes without a flag (7,680 bytes). Both restored the file with its holes and identical contents. Extracting the plain GNU archive wrote all 100 MB to disk.

stay on one filesystem

CommandWhat it does
tar -cf /mnt/backup/home.tar --one-file-system /home
REPLACES ARCHIVE: archive /home but don’t descend into anything mounted inside it

In the test, with a tmpfs mounted inside the tree, GNU tar 1.35 and bsdtar 3.7.4 both kept the empty mount-point directory and left out what was mounted on it. The /home example wasn’t run.

verify and compare

CommandWhat it does
tar -tzf proj.tar.gz > /dev/null && echo archive reads OK
gzip -t proj.tar.gz && sha256sum proj.tar.gz > proj.tar.gz.sha256
sha256sum -c proj.tar.gz.sha256
Read the whole archive, test the gzip layer, and keep a checksum to check after copying

xz -t, zstd -t and bzip2 -t test the other formats. On a truncated archive, GNU tar and bsdtar both exited nonzero.

CommandWhat it does
tar -df proj.tar
output
tar: proj/src/util.c: Warning: Cannot stat: No such file or directory
proj/README.md: Mod time differs
proj/README.md: Size differs
proj/run.sh: Mode differs
GNU only: compare the archive with the files on disk (run from the directory you archived from)

That’s after editing one file, changing one file’s mode and moving another away. With no differences, tar -df prints nothing and exits 0. It reads compressed archives too (tar -dzf proj.tar.gz), and --compare is the long form.

CommandWhat it does
tar -cvWf proj.tar proj
GNU only: read the archive back after writing it (uncompressed archives only)

With -v, -W prints a Verify proj/... line for each member. -W with -z stops with tar: Cannot verify compressed archives.

CommandWhat it does
mkdir cmp && bsdtar -xf proj.tar -C cmp && diff -r proj cmp/proj && echo identical
bsdtar has no -d: extract to a scratch directory and diff instead (works with GNU too)

tarbombs and path traversal

A tarbomb is an archive with no top directory, so it spills its files into wherever you extract it. A hostile archive can go further and hold member names that point outside the directory. Look before you extract.

CommandWhat it does
tar -tf bomb.tar | cut -d/ -f1 | sort -u
output
a.txt
b.txt
c.txt
What top-level names will this create? One name means one directory
mkdir bomb && tar -xf bomb.tar -C bomb
Contain it: extract into a new, empty directory

The hostile test archive held an absolute name (/tmp/tl-tar/pwn-abs.txt), two names with .. (../pwn-dotdot.txt and ok/../../pwn-mid.txt), a symlink link -> /tmp/tl-tar, and a file link/pwn-link.txt written through it. Extracting it into an empty directory:

  • GNU tar 1.35 wrote the absolute name as ./tmp/tl-tar/pwn-abs.txt, inside the directory. It refused both .. names (Member name contains '..'), refused to write through the symlink (link/pwn-link.txt: Cannot open: Not a directory), and exited 2.
  • bsdtar 3.7.4 also wrote the absolute name inside the directory. It refused both .. names (Path contains '..'), refused the symlink (Cannot extract through symlink link/pwn-link.txt), and exited 1.

That holds only for an empty directory. GNU tar 1.35 follows a symlink that’s already there, including one left by an earlier archive. In the test, a directory holding link -> /tmp/tl-tar/outside was given an archive with link/pwn.txt, and GNU tar wrote pwn.txt outside the directory, with no message and exit status 0. The same happened when one archive created the symlink and a second archive wrote through it. -k, --skip-old-files, --no-overwrite-dir and --unlink-first didn’t stop it. When the archive also had a link/ directory entry, plain GNU tar replaced the symlink, but with -k or --keep-directory-symlink the write still went outside. bsdtar never wrote outside: it refused a file under the symlink (Cannot extract through symlink, exit status 1), and when the archive had a link/ directory entry it replaced the symlink with a directory. Its -U also replaced the symlink. GNU tar after 1.35 fixes this (NEWS for 1.35.90), and the online GNU Security page describes the fixed behaviour. Use a fresh, empty directory for every untrusted archive.

CommandWhat it does
tar -xPf trusted.tar
DANGEROUS, OVERWRITES ANYWHERE YOU CAN WRITE: -P keeps absolute names, .. and symlinks. Trusted archives only

With -P, GNU tar wrote all four files outside the extraction directory and exited 0. bsdtar -P wrote the absolute and .. members outside it too, also with exit status 0.

GNU tar also resets an existing directory’s mode to the archived one: a 700 directory became 755. bsdtar did that only with -p or as root. GNU --no-overwrite-dir stops it. The bsdtar man page recommends listing with -tf first, -k to keep existing files, and not extracting as root.

GNU tar vs bsdtar

Each line was run with GNU tar 1.35 and bsdtar 3.7.4 on Linux, unless it says otherwise. Differences already covered above (patterns, compressed stdin, -d, --delete, -k, excludes, setuid, symlinks) aren’t repeated.

  • Verify after writing: GNU has -W. bsdtar doesn’t: bsdtar -cWf w.tar proj failed with Ambiguous option -W f (matches --format and --files-from).
  • Rename members on the way in or out: GNU --transform 's,^proj,proj-1.2,'. bsdtar -s ',^proj,proj-1.2,'. Both gave proj-1.2/README.md.
  • Other formats: bsdtar lists and extracts zip files (bsdtar -tf proj.zip) and creates them (bsdtar -caf proj.zip proj). GNU tar says This does not look like a tar archive.
  • Default format: GNU tar on Debian writes the gnu format (tar --show-defaults printed --format=gnu). The bsdtar man page says it writes restricted pax. Each read the other’s archives in the test.
  • Extended attributes and ACLs: bsdtar archives xattrs by default. GNU needs --xattrs on create and extract, and --acls for ACLs. The bsdtar man page says ACLs are archived by default, except on Mac OS X.
  • Sparse files: GNU needs -S. bsdtar handled them by default.
  • macOS metadata: the bsdtar man page says --mac-metadata is Mac OS X only and the default when creating, so archives made on a Mac can carry extended attributes and ACLs in AppleDouble form, which show up as ._ files elsewhere. --no-mac-metadata alone isn’t enough. For an archive with no ._ files, use tar --no-mac-metadata --no-xattrs -cf x.tar dir (per the bsdtar man page and libarchive issue 2486), or COPYFILE_DISABLE=1 tar --no-xattrs -cf x.tar dir (the variable is read by bsdtar on macOS, per libarchive’s bsdtar.c and Apple’s copyfile.h). Neither was run on macOS. Check man tar on your Mac, because its bsdtar version may differ from 3.7.4.

gotchas

  • -f takes the next word as the archive name. In the test, GNU tar -cfz x.tar proj wrote an uncompressed archive called z and complained x.tar: Cannot stat. Keep f last in a bundle like -czf.
  • tar -x writes paths relative to the current directory, or -C. Check pwd first, especially as root.
  • Member names have to match what tar -tf prints, ./ included, in GNU tar.

sources

Checked against the GNU tar manual on gnu.org (the edition online is for 1.35.90, the development version after 1.35), the GNU tar NEWS file, and the bsdtar man page from libarchive 3.7.4 (tar/bsdtar.1, dated April 23, 2024). On another version, check man tar or info tar on your own machine.

Tested with GNU tar 1.35 (Debian package 1.35+dfsg-3.1) and bsdtar 3.7.4 (libarchive-tools 3.7.4-4+deb13u1) on Debian GNU/Linux 13 (trixie), Linux 6.12, with gzip 1.13, bzip2 1.0.8, xz 5.8.1 and zstd 1.5.7. The ssh rows also ran over a real ssh hop with GNU tar 1.34 and OpenSSH 9.2p1 on Debian 12 (aarch64) on both ends. Not run on macOS.

Sources: