Maintained layer, checked 2026-09-01. The original 2019 post was not empty: it contained one recursive Windows
compactcommand. That complete note is preserved at the end. The maintained guide addresses the title’s other common interpretation—finding ZIP/TAR archives across a drive—without recommending blind whole-drive extraction.
Table of Contents
First Resolve What “Decompress” Means
Two unrelated operations are often called decompression:
- NTFS filesystem compression: Windows stores an ordinary file in compressed form while applications continue to open it normally. Microsoft’s `compact` documentation says
/uremoves this attribute and/srecurses below a directory. - Archive extraction: a ZIP or TAR-family container holds named members that are written into a destination directory. Python exposes these formats through `zipfile` and `tarfile`.
compact does not open ZIP files, and extracting a ZIP does not change NTFS compression. The archived command recursively changed filesystem storage across the current drive. Do not run it merely because the title sounded like “unzip every archive.” For NTFS work, query and test a reviewed directory first; broad volume changes can consume substantial space and encounter protected operating-system files.
Why Blind Whole-Drive Extraction Is Unsafe
An archive is not just a bag of harmless files. Before extraction, account for:
- absolute paths,
..components, Windows drive paths, and non-portable names; - symbolic links, hard links, devices, FIFOs, or other special TAR members;
- duplicate names, file-versus-directory conflicts, and case-only collisions;
- archives that declare enormous output or extreme expansion ratios;
- encrypted members that turn an unattended job into a password-handling problem;
- two archives writing the same destination names;
- a failure after some files have already been created.
The Python documentation explicitly warns that archives require prior inspection. Even TAR’s data extraction filter does not stop every denial-of-service or malicious-input problem. A safe workflow inventories first, extracts into a new isolated directory, applies resource limits, and verifies output before accepting it.
Conservative Workflow
- Keep the source drive unchanged and place output and logs outside the scanned tree.
- Find likely archive filenames, then confirm ZIP/TAR type from file content rather than trusting the extension alone.
- Record archive SHA-256, member count, declared uncompressed size, expansion ratio, and every rejection reason.
- Review the JSON Lines manifest. Do not extract records with
problems. - Select exactly one relative archive path for extraction.
- Extract into a fresh temporary directory with no pre-existing links or files.
- Verify the exact regular-file paths and sizes, write a state record, then rename the completed directory into place.
- Keep the original archive. Deletion is a separate retention decision after backups and application-level checks.
This is intentionally slower than a recursive one-liner. It makes partial failures, collisions, and retries observable.
Reference Python 3.12+ Audit-and-Extract Tool
The following standard-library script supports ZIP and readable TAR variants. Its default mode is inventory only. --extract accepts exactly one archive path from that inventory. It rejects links and special files, traversal and Windows drive paths, duplicate/case-colliding names, encrypted ZIP members, excess member counts, excess declared bytes, and high expansion ratios.
The limits are policy examples, not universal safe values. Lower them for an untrusted source. Run untrusted content with a low-privilege account inside an appropriately constrained environment; this script is a guardrail, not a malware scanner or security boundary.
<details>
<summary>Show <code>archive_audit.py</code></summary>
#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import re
import shutil
import stat
import tarfile
import tempfile
import zipfile
from pathlib import Path, PurePosixPath, PureWindowsPath
SUFFIXES = (
".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2",
".tar.xz", ".txz", ".gz", ".bz2", ".xz",
)
WINDOWS_RESERVED = {
"con", "prn", "aux", "nul",
*(f"com{i}" for i in range(1, 10)),
*(f"lpt{i}" for i in range(1, 10)),
}
def digest(path):
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def normalized_name(raw):
if not raw or "\0" in raw:
raise ValueError("empty or NUL-containing member name")
windows = PureWindowsPath(raw)
posix = PurePosixPath(raw.replace("\\", "/"))
if windows.drive or windows.is_absolute() or posix.is_absolute():
raise ValueError(f"absolute or drive-qualified path: {raw!r}")
if ".." in posix.parts:
raise ValueError(f"parent traversal: {raw!r}")
parts = tuple(part for part in posix.parts if part not in ("", "."))
if not parts:
return None
for part in parts:
stem = part.rstrip(" .").split(".", 1)[0].casefold()
if ":" in part or part.endswith((" ", ".")) or stem in WINDOWS_RESERVED:
raise ValueError(f"non-portable path component: {raw!r}")
return "/".join(parts)
def validate_members(raw_members):
members = []
exact = {}
folded = {}
problems = []
for raw, kind, size in raw_members:
try:
name = normalized_name(raw)
except ValueError as exc:
problems.append(str(exc))
continue
if name is None:
continue
if kind not in {"file", "dir"}:
problems.append(f"unsupported {kind}: {raw!r}")
continue
if name in exact:
if kind == exact[name]["kind"] == "dir":
continue
problems.append(f"duplicate or file/type collision: {raw!r}")
continue
folded_name = name.casefold()
if folded_name in folded and folded[folded_name] != name:
problems.append(
f"case-insensitive collision: {folded[folded_name]!r} / {name!r}"
)
continue
member = {"name": name, "kind": kind, "size": size}
exact[name] = member
folded[folded_name] = name
members.append(member)
for member in members:
for parent in PurePosixPath(member["name"]).parents:
parent_name = parent.as_posix()
if parent_name == ".":
break
if exact.get(parent_name, {}).get("kind") == "file":
problems.append(
f"file blocks child path: {parent_name!r} / {member['name']!r}"
)
return members, sorted(set(problems))
def inspect_archive(path, kind, limits):
raw_members = []
encrypted = False
if kind == "zip":
with zipfile.ZipFile(path) as archive:
for info in archive.infolist():
mode = (info.external_attr >> 16) & 0xFFFF
if info.is_dir():
member_kind = "dir"
elif stat.S_ISLNK(mode):
member_kind = "symlink"
elif mode and stat.S_IFMT(mode) not in (0, stat.S_IFREG):
member_kind = "special file"
else:
member_kind = "file"
encrypted |= bool(info.flag_bits & 0x1)
raw_members.append((info.filename, member_kind, info.file_size))
else:
with tarfile.open(path, "r:*") as archive:
for info in archive.getmembers():
if info.isfile():
member_kind = "file"
elif info.isdir():
member_kind = "dir"
elif info.issym() or info.islnk():
member_kind = "symlink or hard link"
else:
member_kind = "special file"
raw_members.append((info.name, member_kind, info.size))
members, problems = validate_members(raw_members)
files = [member for member in members if member["kind"] == "file"]
unpacked = sum(member["size"] for member in files)
archive_bytes = path.stat().st_size
ratio = unpacked / max(archive_bytes, 1)
if encrypted:
problems.append("encrypted ZIP: password input is deliberately unsupported")
raw_member_count = len(raw_members)
if raw_member_count > limits.max_members:
problems.append(
f"member limit exceeded: {raw_member_count} > {limits.max_members}"
)
if unpacked > limits.max_bytes:
problems.append(f"byte limit exceeded: {unpacked} > {limits.max_bytes}")
if ratio > limits.max_ratio:
problems.append(f"expansion ratio exceeded: {ratio:.1f} > {limits.max_ratio}")
return {
"archive_bytes": archive_bytes,
"file_count": len(files),
"member_count": raw_member_count,
"accepted_member_count": len(members),
"unpacked_bytes": unpacked,
"expansion_ratio": round(ratio, 2),
"problems": sorted(set(problems)),
"members": members,
}
def verify_tree(root, members):
expected = {
member["name"]: member["size"]
for member in members if member["kind"] == "file"
}
actual = {}
problems = []
for base, dirs, files in os.walk(root, followlinks=False):
base_path = Path(base)
kept_dirs = []
for name in dirs:
path = base_path / name
if path.is_symlink():
problems.append(f"extracted symlink: {path.relative_to(root)}")
else:
kept_dirs.append(name)
dirs[:] = kept_dirs
for name in files:
path = base_path / name
relative = path.relative_to(root).as_posix()
if relative == ".archive-state.json":
continue
if path.is_symlink() or not path.is_file():
problems.append(f"non-regular output: {relative}")
else:
actual[relative] = path.stat().st_size
if actual != expected:
problems.append("output file names or sizes differ from the reviewed inventory")
return problems
def extract_one(plan, output):
if plan["problems"]:
raise RuntimeError("selected archive did not pass preflight")
destination = output / plan["destination"]
if destination.exists():
state_path = destination / ".archive-state.json"
state = json.loads(state_path.read_text("utf-8")) if state_path.is_file() else {}
problems = verify_tree(destination, plan["members"])
if state.get("sha256") == plan["sha256"] and not problems:
return {"status": "already-verified", "destination": str(destination)}
raise FileExistsError(f"refusing to merge with existing destination: {destination}")
output.mkdir(parents=True, exist_ok=True)
required = plan["unpacked_bytes"] + max(plan["unpacked_bytes"] // 10, 64 << 20)
if shutil.disk_usage(output).free < required:
raise RuntimeError("insufficient free space including safety margin")
if digest(plan["path"]) != plan["sha256"]:
raise RuntimeError("archive changed after inventory")
with tempfile.TemporaryDirectory(prefix=".partial-", dir=output) as temporary:
temporary_path = Path(temporary)
if plan["kind"] == "zip":
with zipfile.ZipFile(plan["path"]) as archive:
archive.extractall(temporary_path)
else:
if not hasattr(tarfile, "data_filter"):
raise RuntimeError("safe tar extraction requires the data filter")
with tarfile.open(plan["path"], "r:*") as archive:
archive.extractall(temporary_path, filter="data")
if digest(plan["path"]) != plan["sha256"]:
raise RuntimeError("archive changed during extraction")
problems = verify_tree(temporary_path, plan["members"])
if problems:
raise RuntimeError("; ".join(problems))
state = {
"source": plan["relative_path"],
"sha256": plan["sha256"],
"file_count": plan["file_count"],
"unpacked_bytes": plan["unpacked_bytes"],
}
(temporary_path / ".archive-state.json").write_text(
json.dumps(state, indent=2, sort_keys=True) + "\n", "utf-8"
)
temporary_path.rename(destination)
return {"status": "extracted-and-verified", "destination": str(destination)}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument(
"--extract", metavar="RELATIVE_ARCHIVE",
help="extract exactly one reviewed archive; default is inventory only",
)
parser.add_argument("--max-members", type=int, default=100_000)
parser.add_argument("--max-bytes", type=int, default=50 * 1024**3)
parser.add_argument("--max-ratio", type=float, default=200.0)
args = parser.parse_args()
if args.max_members < 1 or args.max_bytes < 1 or args.max_ratio <= 0:
parser.error("resource limits must be positive")
source = args.source.resolve()
output = args.output.resolve()
manifest = args.manifest.resolve()
if not source.is_dir():
raise SystemExit("--source must be an existing directory")
if output == source or output.is_relative_to(source):
raise SystemExit("--output must be outside --source")
if manifest.is_relative_to(source):
raise SystemExit("--manifest must be outside --source")
plans = {}
records = []
walk_errors = []
def on_walk_error(error):
walk_errors.append(f"{type(error).__name__}: {error}")
for base, dirs, files in os.walk(source, followlinks=False, onerror=on_walk_error):
base_path = Path(base)
dirs[:] = [name for name in dirs if not (base_path / name).is_symlink()]
for name in files:
path = base_path / name
if path.is_symlink() or not name.casefold().endswith(SUFFIXES):
continue
relative = path.relative_to(source).as_posix()
try:
if zipfile.is_zipfile(path):
kind = "zip"
elif tarfile.is_tarfile(path):
kind = "tar"
else:
records.append({
"record_type": "archive",
"relative_path": relative,
"kind": "unsupported-or-damaged",
"problems": ["candidate suffix but no supported ZIP/TAR signature"],
})
continue
sha256 = digest(path)
inspected = inspect_archive(path, kind, args)
label = re.sub(r"[^A-Za-z0-9._-]+", "_", relative).strip("._")
path_token = hashlib.sha256(relative.encode("utf-8")).hexdigest()[:12]
destination = f"{(label or 'archive')[:60]}-{path_token}-{sha256[:12]}"
plan = {
"record_type": "archive",
"relative_path": relative,
"kind": kind,
"sha256": sha256,
"destination": destination,
"path": path,
**inspected,
}
plans[relative] = plan
records.append({key: value for key, value in plan.items()
if key not in {"path", "members"}})
except (OSError, tarfile.TarError, zipfile.BadZipFile) as exc:
records.append({
"record_type": "archive",
"relative_path": relative,
"kind": "unreadable",
"problems": [f"{type(exc).__name__}: {exc}"],
})
summary = {
"record_type": "scan-summary",
"source": str(source),
"archives": len(records),
"walk_errors": walk_errors,
"mode": "extract-one" if args.extract else "inventory-only",
}
manifest.parent.mkdir(parents=True, exist_ok=True)
with manifest.open("x", encoding="utf-8") as stream:
for record in (*records, summary):
stream.write(json.dumps(record, sort_keys=True) + "\n")
print(json.dumps(record, sort_keys=True))
if args.extract:
if walk_errors:
raise SystemExit("scan was incomplete; refusing extraction")
plan = plans.get(args.extract)
if plan is None:
raise SystemExit("--extract must exactly match a supported relative_path")
print(json.dumps(extract_one(plan, output), sort_keys=True))
if __name__ == "__main__":
main()
</details>
Inventory First: No Extraction
Save the script as archive_audit.py. Keep all three paths explicit and outside one another:
python3 archive_audit.py \
--source /path/to/mounted-drive \
--output /path/to/separate-output/extracted \
--manifest /path/to/private-logs/inventory-2026-09-01.jsonl
Without --extract, the script never creates an extraction directory. It scans likely ZIP/TAR suffixes but confirms type with zipfile.is_zipfile() or tarfile.is_tarfile(). This avoids trusting a renamed extension while also avoiding an expensive attempt to parse every ordinary file on a drive.
The manifest is opened in exclusive-create mode, so an existing log is not silently overwritten. It contains relative archive names, source-root information, hashes, declared sizes, intended destination names, and rejection details; keep it private if filenames or mount paths are sensitive.
Review Space, Members, and Rejections
For every supported archive, review at least:
problems: must be an empty list before extraction;sha256: identifies the exact archive bytes inspected;file_countandunpacked_bytes: declared regular-file workload;expansion_ratio: declared output divided by archive size;destination: a sanitized name plus prefixes of the relative-path hash and content hash;walk_errorsin the final summary: any scan error makes extraction unsafe because the inventory is incomplete.
The default ceiling is 100,000 members, 50 GiB of declared output, and a 200:1 expansion ratio per archive. These values are not promises that extraction is safe. Archive headers can be hostile, filesystem metadata consumes additional space, and other processes can consume free space after a check. The extraction path therefore also requires the declared output plus the larger of 10% or 64 MiB, using Python’s `shutil.disk_usage()`.
Extract Exactly One Reviewed Archive
Use the exact relative_path copied from a clean inventory record and a new manifest filename:
python3 archive_audit.py \
--source /path/to/mounted-drive \
--output /path/to/separate-output/extracted \
--manifest /path/to/private-logs/extract-photos-2026-09-01.jsonl \
--extract 'relative/path/photos.zip'
The script re-hashes the source before and after extraction, works in a fresh temporary directory, and never merges into an existing destination. TAR extraction explicitly requests filter="data" and fails if that API is unavailable. It then compares every regular output path and size with the reviewed inventory before renaming the directory into place.
The source tree is never written. The output must be outside the source, so an extraction cannot feed new archives back into the same scan. Each relative archive path receives an isolated path-and-content-addressed destination, preventing collisions even when two differently named archives contain identical bytes.
Passwords and Encrypted Archives
The tool deliberately blocks encrypted ZIP members. Do not add a plaintext password to a command line, script, JSONL manifest, shell history, process list, or source repository. Confirm the archive’s provenance first, then use a reviewed format-specific tool that obtains the secret interactively or from an approved secret manager and still extracts into an isolated destination.
Python’s zipfile documentation notes that its decryption is extremely slow. More importantly, a successful password only decrypts bytes; it does not establish that member paths, expansion size, or contents are safe. Re-run equivalent inventory and verification controls around any separate encrypted-archive workflow.
Logs, Idempotency, and Verification Limits
On success, the destination contains .archive-state.json with the source-relative path, SHA-256, file count, and declared output bytes. Repeating extraction for unchanged bytes does not overwrite files: the script verifies the existing tree and reports already-verified. If the archive changes, its hash-derived destination changes. If an existing directory is incomplete or modified, the tool refuses to merge.
Path-and-size equality is a useful non-destructive structural check, not proof that documents are genuine or executables are benign. A SHA-256 identifies the inspected archive but does not authenticate its publisher. Where available, separately verify a publisher signature or trusted checksum, scan content according to local security policy, and open risky formats only in an isolated viewer. Do not execute extracted programs as part of extraction.
Supported Scope and Known Limits
This reference tool intentionally has a narrow scope:
- It supports standard ZIP and TAR formats readable by the installed Python, including common gzip/bzip2/xz-compressed TAR files.
- A standalone
.gz,.bz2, or.xz, a multipart ZIP, RAR, 7z, or a format requiring a missing optional Python compression module is logged or fails; it is not guessed through another extractor. - Candidate discovery starts from common suffixes, so a valid archive with an unrelated filename is not found automatically. Investigate such files separately rather than probing every byte sequence on the drive.
- Links and special TAR filesystem objects are rejected rather than recreated.
- Resource limits reduce risk but do not replace process, CPU, memory, filesystem-quota, and time limits for hostile inputs.
These constraints are features: unsupported or ambiguous input should stop for review rather than silently choose a powerful extraction mode.
Disposable Validation Evidence
The embedded script was syntax-checked and exercised with Python 3.12.3 in disposable local directories. The test set included a normal ZIP, a ZIP containing A.txt and a.txt, a TAR containing a symbolic link, and a TAR member named ../escape.txt.
- Inventory mode created no extraction output.
- The normal ZIP extracted into its isolated hash-named directory and passed path/size verification.
- Repeating the selected extraction returned
already-verifiedwithout overwriting files. - The case collision, symbolic link, and traversal archive were rejected before extraction.
No real drive, production path, or network archive was modified during validation.
Primary Documentation
- Microsoft Learn: `compact` and NTFS compression parameters
- Python: `zipfile`, signature detection, inspection, encryption, and extraction warnings
- Python: `tarfile`, extraction filters, temporary-directory guidance, and remaining risks
- Python: `shutil.disk_usage()`
- Python: secure temporary-directory interfaces
- Python: `hashlib.file_digest()`
—
Original 2019 Archive (Verbatim)
The original export was non-empty. The following is its complete visible body, published and last modified on 2019-06-13. Only invisible trailing whitespace has been normalized for repository formatting. It is preserved as provenance, not as a recommendation to alter an entire drive.
First move present working directory to root of each drive, then type
C:
compact /u /s /a /q /i
