Two colleagues reviewing a backup restore at a workstation with an external drive, while coworkers work in an open-plan office.

An Incremental tar Restore Can Delete Local Files

Fri, Oct 30, 2026

A GNU tar incremental extraction can return success and still surprise an operator by deleting a file that existed in the destination before the restore. That is not a contradiction: in incremental mode, GNU tar is reconstructing recorded directory state, not performing an additive copy. The opposite failure is just as important. Skip a predecessor and later archives may leave behind stale bytes for names whose unchanged payload was intentionally omitted. In both cases, exit status 0 is only one observation, not acceptance of the restored generation. The GNU tar 1.35 manual explicitly says incremental extraction attempts to restore the filesystem state recorded when the archive was created and that multiple levels must be restored in turn to recover the last level.

This article treats the decision as a recovery qualification problem: does a private receiver contain exactly the intended historical generation, by name and literal file bytes? The laboratory uses three self-created archives, one non-renamed app/ directory, ordinary regular files, GNU tar 1.35 and Python standard-library orchestration. It tests deletion, incomplete chains, reversed order, stale retained bytes and pre-extraction guards. It does not qualify live rollback, publication, application consistency, hostile archives or metadata correctness. The receiver remains private even when it passes.

Define the restoration generation before running tar

Start with the generation contract, not with the extraction command. The fixture defines three complete expected states independently of any receiver. G0 contains app/config.txt with the exact bytes CONFIG=v0\n, app/keep.txt with KEEP\n, and app/retired.txt with RETIRED\n. G1 changes config to CONFIG=v1\n, retains keep, removes retired, and adds app/new.txt containing NEW\n. G2 retains G1 and adds app/final.txt containing FINAL\n. Those final newlines are part of the contract.

Acceptance therefore means more than “tar extracted without an error.” For a G2 candidate, the receiver must contain exactly four regular files (config, keep, new and final) with the literal G2 bytes, and no retired member. A G0-only reconstruction may be accepted only as G0, not mislabeled as a current G2 recovery. If provenance, order or destination admission is unresolved, the workflow should hold before extraction rather than create ambiguous state.

Keep three decisions separate. Archive handling decides whether the owned chain is admissible for this lab. Restore qualification decides whether the isolated receiver matches the selected generation. Consumer publication is a later application-owned decision and is not exercised here. That same separation appears in Refonte Learning’s adjacent discussion that archive extraction and publication are separate decisions, but this article does not reuse its Python filtering or hostile-input workflow. The present archives are synthetic, owned and trusted for the experiment.

The exclusions are deliberate: no symlinks, hard links, directory renames, arbitrary filenames, database transactions, filesystem metadata acceptance, concurrent writers, crash durability or live-service handoff. A fresh owned receiver is an operational control for this narrow reconstruction test, not a universal archive-security boundary.

Separate payload, directory membership and creation state

GNU listed incrementals involve three different records, and confusing them is the root of many restore errors. First, the archive payload contains file data selected for that particular backup. Second, incremental archives carry directory membership metadata called a dumpdir. Third, the snapshot file used during creation records state that helps a later backup decide what changed.

--listed-incremental=FILE is GNU-specific terminology. During creation, GNU tar consults and updates a standalone snapshot file; if it does not exist, the result is a level-0 backup. During extraction, the snapshot contents are not needed because the restore metadata is in the archive itself, so GNU documents /dev/null as the usual syntactic argument.

A dumpdir is GNU tar’s record of a dumped directory’s membership. In the released 1.35 format, Y means the named entry is contained in the archive, while N means the name existed in the directory at backup time but its data was not dumped because it had not changed since the previous backup. The lab exercises only Y and N; it does not generalize to rename directives or every GNU incremental format detail.

The creation snapshot is a different artifact. GNU’s snapshot-format documentation records time and directory metadata, including dumpdir content, device and inode fields used for incremental selection. It guides future backup creation. It is not the source of restore membership during extraction.

Read N as retained membership, not a deletion instruction

An N keep.txt record does not mean “delete keep.txt.” It means keep belonged to the directory at backup time, while its unchanged payload was omitted from this increment. The receiver must therefore already contain the correct predecessor bytes if the selected generation depends on them. Conversely, a name absent from the stored directory membership can be removed during incremental extraction because GNU tar is reconstructing the historical directory state. This is why payload listings and directory membership answer different questions: the payload says which bytes this archive contributes; the dumpdir says which names belong in the directory. Neither alone proves the complete generation.

Pin the laboratory and preserve its source archives

The preparation run is identified precisely: CPython 3.12.14; GNU tar 1.35 at /bin/tar; Linux 6.18.57 x86_64; glibc 2.39; Clang 22.1.3; UID 0. The controlled subprocess environment was PATH=/bin:/usr/bin, LC_ALL=C, LANG=C, and TZ=UTC. The run began at 2026-10-30T07:39:04.866395+00:00 and finished at 2026-10-30T07:39:04.962743+00:00. Those timestamps identify evidence; they are not a benchmark.

The runtime tar binary SHA-256 was 6453fce2cf2dde32b86557bb44f533afb3b5171b59b8d21cc797b9b20d9c7b06. The fixture itself is identified by SHA-256 927e2c85375ca08ed75d1d029f1377aa019aed49b8a5482314462985dff569dc. Because UID was 0, the run does not establish an unprivileged-permission result. Extraction used --no-same-owner and --no-same-permissions, but permissions and metadata are explicitly outside the acceptance contract.

The source tree is quiescent while each archive is created. Every archive is generated from owned synthetic data inside a TemporaryDirectory; Python documents that object as a context manager whose tree is removed on normal context completion. The fixture positively observes normal cleanup, not cleanup after crashes, signals or hostile concurrent mutation.

Commands are run as argument lists with an explicit environment, captured stdout/stderr, recorded return codes and a ten-second timeout. Those mechanisms are documented by Python’s subprocess.run; the timeout is only a guard, and no timeout failure or performance target is tested.

Archive bytes are digested at creation and rechecked after restoration. That is different from the neighboring problem of preserving independent backup generations, where shared inode identity is the hazard. Here the tar files remain unchanged; the variable under test is how an ordered chain reconstructs a destination.

Run the complete twelve-case restoration fixture

The fixture below is the complete 209-line source used for the preparation evidence. Save it unchanged, including the final newline, as tar_incremental_restore_lab.py. It refuses a tar executable whose first version line is not exactly tar (GNU tar) 1.35. It creates all archives itself, checks their ordinary member listings and the expected Y/N dumpdir records before any restore interpretation, then runs twelve cases. A mismatch raises an assertion, leaves the report status as FAIL, and exits nonzero; there is no narrative override for a stopped experiment.

"""Owned GNU tar restore experiment; no supplied archive or live destination."""
import hashlib
import json
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path

G0 = {"app/config.txt": b"CONFIG=v0\n", "app/keep.txt": b"KEEP\n",
      "app/retired.txt": b"RETIRED\n"}
G1 = {"app/config.txt": b"CONFIG=v1\n", "app/keep.txt": b"KEEP\n",
      "app/new.txt": b"NEW\n"}
G2 = {**G1, "app/final.txt": b"FINAL\n"}
ORDER = ["base", "delta1", "delta2"]
ENV = {"PATH": os.defpath, "LC_ALL": "C", "LANG": "C", "TZ": "UTC"}


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def digest(data):
    return hashlib.sha256(data).hexdigest()


def inventory(directory):
    result = {}
    for path in sorted(directory.rglob("*")):
        require(not path.is_symlink(), "Unexpected symlink")
        if path.is_file():
            result[str(path.relative_to(directory))] = path.read_bytes()
        else:
            require(path.is_dir(), "Unexpected file type")
    return result


def describe(mapping):
    return {name: {"text": data.decode("ascii"), "hex": data.hex(),
                   "sha256": digest(data)} for name, data in sorted(mapping.items())}


def seed(directory, mapping):
    for name, data in mapping.items():
        path = directory / name
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(data)


def run(report):
    tar = shutil.which("tar", path=os.defpath)
    require(tar is not None, "GNU tar is required")

    def command(arguments):
        proc = subprocess.run([tar, map(str, arguments)], env=ENV,
                              capture_output=True, text=True, timeout=10)
        report["commands"].append({"argv": proc.args, "returncode": proc.returncode,
                                   "stdout": proc.stdout, "stderr": proc.stderr})
        require(proc.returncode == 0, "tar operation failed")
        return proc.stdout

    version = command(["--version"])
    require(version.splitlines()[0] == "tar (GNU tar) 1.35", "Version baseline mismatch")
    report["environment"].update(tar_version=version, tar_path=tar,
                                 tar_sha256=digest(Path(tar).read_bytes()))
    report["contracts"] = {"G0": describe(G0), "G1": describe(G1), "G2": describe(G2)}
    with tempfile.TemporaryDirectory(prefix="owned-incremental-tar-") as temporary:
        root = Path(temporary)
        source = root / "source"
        source.mkdir()
        snapshot = root / "creation.snar"
        archives = {}
        catalog = {}
        report["archive_evidence"] = []

        def create(label, payload, flags):
            archive = root / (label + ".tar")
            command(["--create", "--format=gnu", "--sort=name",
                     "--listed-incremental=" + str(snapshot), "--file", archive,
                     "--directory", source, "app"])
            names = command(["--list", "--file", archive]).splitlines()
            require(names == ["app/", sorted(payload)], "Unexpected payload members")
            listing = command(["--list", "--incremental", "--verbose", "--verbose",
                               "--file", archive])
            statuses = [line for line in listing.splitlines() if line.startswith(("Y ", "N "))]
            require(statuses == flags, "Unexpected dumpdir control records")
            archives[label] = archive
            catalog[label] = digest(archive.read_bytes())
            report["archive_evidence"].append({"id": label, "sha256": catalog[label],
                "members": names, "dumpdir_records": statuses,
                "snapshot_sha256_after_creation": digest(snapshot.read_bytes())})

        seed(source, G0)
        create("base", G0, ["Y config.txt", "Y keep.txt", "Y retired.txt"])
        (source / "app/retired.txt").unlink()
        seed(source, {"app/config.txt": G1["app/config.txt"], "app/new.txt": G1["app/new.txt"]})
        create("delta1", {"app/config.txt": G1["app/config.txt"], "app/new.txt": G1["app/new.txt"]},
               ["Y config.txt", "N keep.txt", "Y new.txt"])
        seed(source, {"app/final.txt": G2["app/final.txt"]})
        create("delta2", {"app/final.txt": G2["app/final.txt"]},
               ["N config.txt", "Y final.txt", "N keep.txt", "N new.txt"])
        require(inventory(source) == G2, "Source generation mismatch")
        snapshot.rename(root / "creation-snapshot-retained.snar")
        require(not snapshot.exists(), "Creation snapshot still at original path")
        report["catalog"] = {"order": ORDER, "sha256": catalog}
        cases = report["cases"]

        def extract(destination, sequence, incremental=True, snapshot_argument="/dev/null"):
            for label in sequence:
                arguments = ["--extract", "--no-same-owner", "--no-same-permissions"]
                if incremental:
                    arguments.append("--listed-incremental=" + str(snapshot_argument))
                command([*arguments, "--file", archives[label], "--directory", destination])

        def ordinary_case(label, sequence, expected, decision, initial=None, incremental=True,
                          snapshot_argument="/dev/null"):
            destination = root / label
            destination.mkdir()
            seed(destination, initial or {})
            before = inventory(destination)
            first_command = len(report["commands"])
            extract(destination, sequence, incremental, snapshot_argument)
            actual = inventory(destination)
            require(actual == expected, label + " unexpected restored bytes")
            require(sorted(str(p.relative_to(destination)) for p in destination.rglob("*")
                           if p.is_dir()) == ["app"], label + " directory mismatch")
            cases.append({"case": label, "sequence": sequence, "incremental": incremental,
                          "before": describe(before), "after": describe(actual),
                          "matches_G2": actual == G2, "decision": decision,
                          "command_range": [first_command, len(report["commands"])], "pass": True})

        ordinary_case("T01_base", ["base"], G0, "ACCEPT_G0_ONLY")
        ordinary_case("T02_plain_merge", ORDER, {**G2, "app/retired.txt": G0["app/retired.txt"]},
                      "REJECT_STALE_MEMBER", incremental=False)
        ordinary_case("T03_last_only", ["delta2"], {"app/final.txt": G2["app/final.txt"]},
                      "REJECT_MISSING_PREDECESSORS")
        ordinary_case("T04_missing_middle", ["base", "delta2"],
                      {"app/config.txt": G0["app/config.txt"], "app/keep.txt": G0["app/keep.txt"],
                       "app/final.txt": G2["app/final.txt"]}, "REJECT_STALE_AND_MISSING")
        ordinary_case("T05_reverse", list(reversed(ORDER)), G0, "REJECT_WRONG_GENERATION")
        ordinary_case("T06_ordered", ORDER, G2, "ACCEPT_RESTORED_CANDIDATE")
        outside = {"outside.txt": b"OUTSIDE\n"}
        ordinary_case("T07_populated_destination", ["base"], {**G0, outside},
                      "REJECT_IN_PLACE_RESTORE_WORKFLOW",
                      initial={"app/local-only.txt": b"LOCAL\n", outside})
        ordinary_case("T08_wrong_retained_bytes", ["delta1", "delta2"],
                      {**G2, "app/keep.txt": b"WRONG\n"}, "REJECT_UNVERIFIED_BASE",
                      initial={**G0, "app/keep.txt": b"WRONG\n"})
        unused = root / "unused-restore.snar"
        ordinary_case("T09_missing_snapshot", ORDER, G2, "ACCEPT_WITHOUT_CREATION_SNAPSHOT",
                      snapshot_argument=unused)
        require(not unused.exists(), "Extraction created the unused snapshot argument")
        cases[-1]["snapshot_argument_exists_after"] = False

        def guarded_case(label, sequence, initial, accepted):
            destination = root / label
            destination.mkdir()
            seed(destination, initial)
            before = inventory(destination)
            count = len(report["commands"])
            reason = None
            if any(destination.iterdir()):
                reason = "destination_not_empty"
            elif sequence != ORDER:
                reason = "catalog_order_mismatch"
            elif any(digest(archives[name].read_bytes()) != catalog[name] for name in sequence):
                reason = "archive_digest_mismatch"
            if reason is None:
                extract(destination, sequence)
            actual = inventory(destination)
            if accepted:
                require(reason is None and actual == G2, label + " acceptance mismatch")
            else:
                require(reason is not None and actual == before, label + " hold changed files")
                require(len(report["commands"]) == count, label + " hold reached extraction")
            cases.append({"case": label, "sequence": sequence, "hold_reason": reason,
                          "before": describe(before), "after": describe(actual),
                          "extraction_commands": len(report["commands"]) - count,
                          "decision": "ACCEPT_PRIVATE_CANDIDATE" if accepted else "HOLD_BEFORE_EXTRACTION",
                          "pass": True})

        guarded_case("T10_guard_nonempty", ORDER, {"app/local-only.txt": b"LOCAL\n"}, False)
        guarded_case("T11_guard_order", list(reversed(ORDER)), {}, False)
        guarded_case("T12_guarded_restore", ORDER, {}, True)
        require(len(cases) == 12 and inventory(source) == G2, "Final inventory mismatch")
        require(all(digest(archives[name].read_bytes()) == catalog[name] for name in ORDER),
                "Archive changed during restoration")
        report["source_and_archives_unchanged"] = True
    require(not root.exists(), "Owned temporary workspace survived cleanup")
    report.update(status="PASS_12_CASES", owned_workspace_removed=True)


if name == "__main__":
    report = {"status": "FAIL", "cases": [], "commands": [],
              "started_utc": datetime.now(timezone.utc).isoformat(),
              "source_sha256": digest(Path(__file__).read_bytes()),
              "environment": {"python": sys.version, "platform": platform.platform(),
                              "uid": os.getuid(), "controlled_env": ENV}}
    try:
        run(report)
    except Exception as error:
        report["failure"] = {"type": type(error).__name__, "message": str(error)}
    report["finished_utc"] = datetime.now(timezone.utc).isoformat()
    print(json.dumps(report, indent=2))
    sys.exit(0 if report["status"] == "PASS_12_CASES" else 1)

Run it only in an owned laboratory environment:

python3 tar_incremental_restore_lab.py > tar-incremental-results.json

Inspect both the process exit status and the JSON. The supplied preparation result was process exit 0 with status: PASS_12_CASES. Every invoked tar operation returned 0. “PASS” includes negative controls whose correct decision is rejection or hold; it does not mean every receiver was acceptable. The final JSON also recorded source_and_archives_unchanged: true and owned_workspace_removed: true.

The command wrapper is intentionally simple. It resolves tar through os.defpath, runs it without a shell, captures both streams, records the exact argv and requires return code 0. GNU’s utility manual documents exit 0 as successful termination of the requested operation. That status does not say that the operator selected the right predecessor set or that the receiver now equals G2; those are independent fixture assertions.

Archive creation is also qualified before restore cases begin. For each of base, delta1 and delta2, the fixture checks the payload member listing, then lists the archive with incremental interpretation and two verbose options to recover dumpdir status lines. The expected status records are literal fixture preconditions. If they differ, the run fails before later cases can be used as evidence. GNU documents the double-verbose incremental listing as a way to expose directory membership status.

Keep the expected generation independent of the receiver

G0, G1 and G2 are declared before archive creation and before any extraction. inventory() walks the receiver, rejects symlinks, accepts only files or directories, and reads file bytes. A candidate passes only when that measured mapping equals the authored generation mapping. This avoids circular validation: the expected set is not derived from whatever tar happened to leave behind.

The timing of evidence matters. The temporary workspace is removed by normal TemporaryDirectory context exit, then the script asserts that the root no longer exists, then the final JSON is printed. The JSON is therefore post-cleanup reporting, not durable pre-cleanup logging. Python documents normal TemporaryDirectory context cleanup, but that does not establish crash-safe or hostile-concurrency cleanup.

The fixture’s ten-second timeout, normal cleanup and root execution are all boundaries. No timeout was injected. No crash cleanup was tested. No unprivileged extraction was tested. These observations qualify the supplied run only under its stated environment and scope.

Establish what the base archive actually reconstructs

T01 extracts only base with incremental mode into an empty receiver. It reconstructs G0 exactly and is therefore labeled ACCEPT_G0_ONLY. It is not G2, and the fixture records matches_G2: false. This is the first reminder that a successful restore can be correct for one generation and wrong for another.

The generations and archive contributions are compact:

Generation

Complete directory state

Archive payload contribution

Dumpdir evidence

G0 / base

config=v0, keep, retired

config, keep, retired

Y config.txt, Y keep.txt, Y retired.txt

G1 / delta1

config=v1, keep, new

config=v1, new

Y config.txt, N keep.txt, Y new.txt

G2 / delta2

config=v1, keep, new, final

final

N config.txt, Y final.txt, N keep.txt, N new.txt

The N entries explain why later payload listings are not snapshots. Delta2 contributes only final.txt, yet its directory record says config, keep and new are members of G2. Correct bytes for those retained names must arrive from earlier state. The base creation, member listing and dumpdir checks must pass before any downstream reconstruction comparison is meaningful. GNU’s documented Y/N meanings support that distinction between included payload and retained membership.

Observe deletion in an already populated receiver

T07 begins with a populated receiver containing app/local-only.txt with LOCAL\n plus an outside.txt sentinel containing OUTSIDE\n. Incremental extraction of base returns success. Afterwards, app/local-only.txt is gone, G0 exists under app/, and outside.txt remains.

That result matches GNU tar’s documented incremental-restore model: when extracting an incremental archive, tar attempts to restore the filesystem state present when the archive was created and, in particular, deletes files that did not exist in recorded directories at that time. The observation therefore fails an additive-copy expectation even though tar exits 0.

Operationally, this is why “restore into that directory and see what happens” is the wrong experiment. If the destination already contains local state, incremental reconstruction may remove names that are not part of the historical directory membership. T07 is deliberately classified REJECT_IN_PLACE_RESTORE_WORKFLOW; its purpose is to show that destination state is part of the recovery decision.

Bound the deletion claim to the restored directory

The sentinel proves one narrow fact in this fixture: a file outside the restored app/ path survived this specific trusted archive replay while an extra file inside app/ was removed. It does not prove universal path containment, safety against malicious archives, behavior for symlinks or renames, or every filesystem object type. GNU’s integrity guidance explicitly warns that incremental restore archives should come from a trusted process because hostile input can alter data beyond the intended working area. The correct response here is an owned fresh receiver and explicit admission checks, not a broader security claim.

Compare plain extraction with incremental reconstruction

T02 and T06 use the same archive order (base, delta1, delta2) but different extraction modes. T02 performs ordinary extraction without --listed-incremental. The payloads merge successfully: config becomes v1, new appears, final appears, keep remains. But retired.txt, which disappeared from G1, also remains because plain extraction never applies the incremental directory cleanup semantics. The result is {G2 + retired} and the correct decision is REJECT_STALE_MEMBER.

T06 applies incremental extraction to the same ordered sequence. Base creates G0. Delta1’s directory state removes retired while retaining keep and supplying new/config bytes. Delta2 retains config, keep and new and supplies final. The receiver matches G2 exactly, so the fixture calls it ACCEPT_RESTORED_CANDIDATE, still a private lab candidate, not a published service state.

This contrast is central to GNU tar listed-incremental restoration. The archive chain is not just a series of file payloads; its directory records participate in reconstruction. GNU’s manual says --incremental and --listed-incremental have the relevant incremental behavior for listing/extraction and documents sequential restoration for multiple levels. Choosing plain versus incremental extraction is therefore part of the acceptance contract, not a cosmetic option difference.

This also explains why a GNU tar incremental restore can delete files. The useful question is not whether deletion happened in the abstract, but whether incremental mode removed a name because that name was absent from the archive’s recorded directory membership. In this owned fixture, retired.txt is the intended historical deletion and local-only.txt is the warning that the same reconstruction semantics can act on pre-existing receiver state. GNU documents the state-reconstruction behavior; the fixture supplies the measured examples. Keeping those two evidence classes separate avoids turning one test case into an unsupported claim about all archives or filesystems.

Reject missing or reversed predecessor sequences

T03 extracts only delta2. Every tar command succeeds, but the receiver contains only app/final.txt. The N entries for config, keep and new describe membership without supplying their bytes, so an empty receiver cannot materialize them. The correct decision is REJECT_MISSING_PREDECESSORS.

T04 restores base and then delta2, skipping delta1. The receiver ends with config still at the exact base bytes CONFIG=v0\n, keep correct, final present and new missing. Delta2 knows config and new belong in the directory, but it does not carry replacement data for either. This is REJECT_STALE_AND_MISSING.

T05 replays delta2, delta1, base. In this particular fixture it finishes as G0. That is REJECT_WRONG_GENERATION, not evidence that every reversed chain always converges to base. The general rule is narrower: listed incrementals depend on predecessor state and documented restore order. GNU says that restoring the exact last level requires restoring all backup levels in turn.

This resembles other ordered-recovery problems only at a high level. Refonte Learning’s article on why transaction-level recovery is a separate workflow deals with committed database events and business cutoffs. This lab has no transactions; its order dependency comes from filesystem generations, directory membership and omitted unchanged payloads.

Separate command success from generation completeness

The negative candidates above did not rely on a tar diagnostic. The fixture recorded return code 0 for every invoked tar operation, including T03, T04 and T05. Rejection came from comparing complete receiver names and bytes against the independently authored target. Do not extrapolate that GNU tar will always diagnose a missing predecessor, or that every incomplete/reordered chain will produce the same visible symptoms. GNU’s return-status documentation defines 0 as successful termination of the requested tar operation; it does not certify an external recovery contract. The operational rule is to verify the intended generation rather than infer completeness from command success.

Prove that retained names can hide incorrect bytes

T08 is the most important stale-byte control because the final directory names look plausible. Its receiver is deliberately seeded with G0 except that app/keep.txt contains WRONG\n instead of KEEP\n. The case then extracts delta1 followed by delta2, omitting base from replay.

Delta1’s dumpdir says N keep.txt: keep existed in G1, but its payload was not archived because it was unchanged at creation time. Delta1 updates config to CONFIG=v1\n, removes retired, and adds new. It does not overwrite keep. Delta2 again reports N keep.txt; it adds final and likewise contributes no keep payload. The final receiver therefore has exactly the G2 path set (config, keep, new and final), but keep still contains WRONG\n.

That receiver would pass a names-only reconciliation. It can also look convincing in a payload-centric review because the later archives contain the changed files one expects. It fails the actual generation contract because one retained name carries predecessor bytes that were never established as trustworthy. The fixture decision is REJECT_UNVERIFIED_BASE.

This is the operational meaning of GNU tar dumpdir N entries: an N record is positive membership evidence but not authoritative replacement data. The GNU dumpdir documentation defines N as a file present at backup time whose unchanged content was not dumped. It is not a deletion instruction and it is not a byte checksum.

The repair is not to invent or guess the missing value. Obtain the required trusted predecessor, verify its identity, and rebuild from a fresh empty receiver. Neither a filename nor a digest of an unavailable payload can recreate lost bytes. If the predecessor cannot be established, the reconstruction remains on hold or rejected for the target generation.

For reviewers trying to validate a tar incremental backup chain, T08 is the control that prevents a weak “all expected names exist” rule. A later increment can confirm that a name belonged to the historical directory while simultaneously omitting that file’s bytes. The byte source is then predecessor state, so predecessor qualification is part of generation qualification. Missing files are an obvious restore failure; stale unchanged files are subtler because the directory can look complete. Both conditions are visible only when the expected generation is defined independently of the replay.

For this fixture the exact byte assertion is simple: accepted G2 requires app/keep.txt == b"KEEP\n". b"WRONG\n" is a rejection even though every extraction command returned 0 and the complete path set matched G2. This is why validating a tar incremental backup chain must reconcile both membership and bytes.

Reconcile names, bytes and the snapshot argument

The twelve-case ledger separates observed receiver state from the operational decision:

Case

Recorded receiver or guard result

Decision

T01_base

Base alone reconstructs G0, not G2

ACCEPT_G0_ONLY

T02_plain_merge

Plain ordered extraction retains retired.txt

REJECT_STALE_MEMBER

T03_last_only

delta2 supplies final.txt only

REJECT_MISSING_PREDECESSORS

T04_missing_middle

base + delta2 leaves config=v0 and lacks new.txt

REJECT_STALE_AND_MISSING

T05_reverse

reverse order ends at G0 in this fixture

REJECT_WRONG_GENERATION

T06_ordered

ordered incremental replay matches G2

ACCEPT_RESTORED_CANDIDATE

T07_populated_destination

local-only inside app disappears; outside sentinel survives

REJECT_IN_PLACE_RESTORE_WORKFLOW

T08_wrong_retained_bytes

keep.txt remains WRONG\n

REJECT_UNVERIFIED_BASE

T09_missing_snapshot

ordered replay matches G2; unused snapshot path stays absent

ACCEPT_WITHOUT_CREATION_SNAPSHOT

T10_guard_nonempty

zero extraction commands; receiver unchanged

HOLD_BEFORE_EXTRACTION

T11_guard_order

zero extraction commands; empty receiver unchanged

HOLD_BEFORE_EXTRACTION

T12_guarded_restore

three extraction commands; exact G2

ACCEPT_PRIVATE_CANDIDATE

T09 isolates a frequently misunderstood point. Before restore testing, the fixture renames the creation snapshot to creation-snapshot-retained.snar. For T09 it passes an unused, nonexistent path as the --listed-incremental argument during extraction. The restore still matches G2, and the unused path remains nonexistent. That agrees with GNU documentation stating that, for extraction, the actual snapshot content is not accessed and the argument is syntactic.

This does not establish that creation snapshots are disposable. GNU’s snapshot format records creation metadata used to decide later incremental content. Successful extraction without the original snapshot only qualifies restore behavior. Future backup creation is a separate responsibility. Likewise, release archive selection has its own contract; Git export rules are unrelated to this GNU tar creation snapshot.

A per-run digest catalog is evidence, not self-authentication

The fixture records ordered labels base, delta1, delta2 and a SHA-256 for each archive. Before guarded extraction it checks that the requested sequence equals the catalog order and that each current archive digest still equals its own creation-time record. This detects accidental substitution or mutation relative to that record. It does not authenticate who created the catalog, prove archive trust by itself, or restore omitted bytes.

Archive hashes may differ across independent creations because tar metadata can differ. A new run must validate against its own trusted creation record, not against the reference run’s archive digests. The fixture source hash is different: 927e2c...569dc identifies the exact 209-line code printed here, including its final newline. GNU’s integrity guidance is the reason to keep digest comparison and trust provenance separate: a checksum comparison against an untrusted catalog does not establish that the archive chain itself came from a trusted backup process.

Admit a restore only after destination and chain checks

T10 and T11 implement pre-extraction policy rather than relying on tar to reject bad workflow choices. T10 seeds the destination with app/local-only.txt; the guard detects that the destination is nonempty, sets destination_not_empty, executes zero extraction commands and confirms the receiver is unchanged. T11 starts empty but requests the reverse sequence; the guard sets catalog_order_mismatch, again executes zero extraction commands and leaves the receiver untouched. Both decisions are HOLD_BEFORE_EXTRACTION.

T12 passes the same policy: empty owned receiver, exact catalog order and current archive digests matching the per-run creation record. It then executes three incremental extraction commands and independently verifies exact G2. Its decision is ACCEPT_PRIVATE_CANDIDATE.

This is the point where an operational checklist becomes more useful than another tar option. Archive order is checked before extraction, the empty-directory policy prevents accidental destination cleanup from becoming part of the experiment, and the final comparison validates the restored state against literal expected bytes. These are harness policies layered around GNU tar; they should not be misattributed as automatic enforcement by the utility.

The digest-mismatch guard is present in code but was not separately fault-injected in the supplied twelve cases. Treat that branch as inspected implementation, not as an executed negative control. The distinction matters in evidence review: a safeguard can exist without its failure path having been demonstrated.

These guards are proposed operational policy for this fixture. GNU tar does not automatically enforce “destination must be empty” or “operator supplied the catalog’s intended order.” The manual documents incremental behavior; the harness adds admission checks around it. A practical preflight is therefore concise:

  • Identify the requested generation and its independent complete name/byte contract.

  • Verify the owned archive identities against a trusted per-run record and require the intended order.

  • Require a fresh, owned, empty receiver; otherwise hold before extraction.

  • Replay in incremental mode.

  • Reconcile the complete receiver against the target generation before assigning candidate status.

Recover by rebuilding a private candidate

When a candidate fails, preserve the report and do not “repair” the receiver in place until its evidentiary value is understood. Keep the source archives unchanged, identify the missing or untrusted predecessor, obtain it from the trusted backup set, and start again in a new owned empty destination. This makes the next candidate explainable: one chain, one order, one receiver, one independently defined target.

For a missing middle archive such as T04, the repair is to obtain the required trusted delta1 and replay base → delta1 → delta2. For T08, the repair is to establish a trustworthy predecessor that supplies the correct KEEP\n bytes before later N records rely on them. For a nonempty destination like T10, the response is not to waive the guard; allocate a fresh receiver. If provenance cannot be resolved, stop rather than invent data.

Preserve enough evidence to distinguish the failed reconstruction from the replacement attempt: fixture/source hash, tool versions, archive IDs and per-run digests, requested order, target generation, command records, observed inventory and decision. The GNU integrity guidance supports keeping trust in scope: incremental restores should come from a trusted process, and empty-directory isolation does not turn arbitrary input into safe input.

An accepted private candidate is still not a tested rollback mechanism. This lab does not exercise atomic replacement, live-service publication, consumer handoff, databases, application transactions or business approval of a recovery point. Stop the filesystem workflow at the evidence boundary.

Assign ownership to the acceptance decision

A recovery review works better when ownership follows the evidence. The backup-chain owner establishes archive provenance, identity and order. The restoration owner constructs the isolated receiver and records command/state evidence. The application or service owner decides whether a technically correct historical generation is appropriate for any later use. No role should infer another role’s approval from tar exit 0 or from a fixture pass.

Evidence state

Operational decision

Owner action

Exact G0, and G0 is the requested point

ACCEPT_G0_ONLY

Record that only G0 is qualified; do not call it G2

Wrong members, stale bytes, missing files or wrong generation

Reject reconstruction

Preserve evidence, obtain required trusted predecessor, rebuild fresh

Destination nonempty, order wrong, or provenance unresolved

HOLD_BEFORE_EXTRACTION

Resolve admission condition before any tar extraction

Exact G2 after admitted ordered replay

ACCEPT_PRIVATE_CANDIDATE

Keep private and pass evidence to separately owned application review

This matrix prevents a common category error: “the test passed” is not equivalent to “publish the restore.” In the supplied result, PASS_12_CASES means all twelve expected positive and negative outcomes matched their authored assertions. It includes cases whose correct outcome is rejection or hold.

Carry the evidence and the limits into review

Retain the fixture SHA-256; GNU tar version and executable hash; Python version; kernel/glibc context; UID; controlled environment; run timestamps; archive order and per-run digests; expected G0/G1/G2 bytes; per-case command ranges; final inventories; and the resulting decision. Explicitly identify executed controls: populated destination deletion, missing predecessors, missing middle, reverse order, wrong retained bytes, absent extraction snapshot path, nonempty guard, order guard and accepted guarded replay.

Also identify what was not qualified: the digest-mismatch branch was not injected; permissions and metadata were not accepted; UID 0 means unprivileged behavior is unknown; crash and interrupted cleanup were not tested; symlinks, hard links, renames, malicious archives, network filesystems and concurrent writers were excluded. Unsupported environments should be marked unqualified, not silently promoted to passed.

The preparation run is evidence from 2026-10-30, not a timeless property of every GNU tar build. A different executable or rerun should carry its own date, versions, hashes and result. The safest reviewer question is always concrete: “Which exact generation, in which exact receiver, was compared to which independent file-and-byte contract?”

Build practical system administration recovery skills

The lab’s final boundary is narrow but useful: an accepted receiver is a private candidate whose archive order, destination admission, directory membership and literal bytes have been reconciled. It is not production rollback approval. That habit of separating command success from the state a system is supposed to have is part of broader system administration and recovery skills.

Refonte Learning’s System Administration programme page currently lists Windows and Linux systems, command-line work, installation/configuration, networking, troubleshooting, security, virtualization, cloud management, Backup and Recovery Solutions, Backup and Disaster Recovery, and a capstone project. It states a dedication of 10 to 12 hours per week and describes practical work that includes system backups and recoveries. The page also names Windows Server, Linux, Active Directory and PowerShell among its operating-system and tool coverage.

For readers building recovery judgment, the relevant invitation is simply to review the Linux, command-line and backup/recovery curriculum. This article does not claim that GNU tar listed-incremental restoration, this Python fixture or these exact versions are named programme modules, and it makes no promise about external internship or employment outcomes.