Skip to content

unpack: open any archive beside itself, never into something that exists

The problem

Unpacking goes wrong in a handful of familiar ways. A zip with forty loose files sprays them across Downloads. Always unpacking into a new folder gives proj-extracted/proj/. A second run writes into the first. The name lies: a .zip that is a tar, a download with no extension, a .gz with a tar inside. And an entry named ../../.bashrc writes outside the folder you chose.

The common fix, unpack into a scratch folder in /tmp and move it at the end, adds two problems of its own: from another disk the "move" is a full copy, and mkdtemp makes the scratch folder private, so the result comes out mode 700.

The method

  • Scratch beside the destination. A hidden .unpack-XXXX folder in the destination folder sits on the same disk, so placing the result is one rename. Its mode is reset to what the umask gives a new folder.
  • Claim with mkdir, then rename onto the claim. mkdir NAME-extracted succeeds for exactly one caller. POSIX rename() replaces that empty folder with the scratch folder in one step, and fails if anything got into it, so the tool moves on to -2. Eight copies racing on one archive give eight whole, separate folders.
  • Kind from the first bytes, not the name; after decompressing a stream, check for a tar (ustar at byte 257) inside.
  • Lift a lone folder, except app bundles.
  • Keep entries inside. Python's tar data filter refuses .., links pointing out and devices; each skip is named and the rest unpacks. A truncated archive's output is kept as NAME-partial, exit 1.
def claim(base, source):
    n = 1
    while True:
        target = base if n == 1 else f"{base}-{n}"
        n += 1
        try:
            os.mkdir(target)          # atomic: one winner per name
        except FileExistsError:
            continue
        try:
            os.rename(source, target) # replaces the empty claim, or fails
            return target
        except OSError:
            continue                  # someone wrote into it; next name
unpack release.tar.gz          # .../release-extracted
unpack download -o ~/scratch   # no extension: read from its bytes

Limits

  • 7z, rar, iso and friends go to bsdtar, then 7-Zip, under their own path rules.
  • No size limit: a zip bomb fills the disk.
  • Zip symlinks come out as text files holding the target.
  • Disk images and installers need the system's tools; this stays with archives.
  • Python 3.12+ (tar data filter); zst needs 3.14+ or the zstd command.

Code

methods/unpack/ in the methods repository: one Python standard-library file and a 32-check test that builds every archive it needs, including unsafe tar entries, a truncated download and eight racing runs.