ドライブ内のアーカイブを安全に探して展開する:棚卸し・隔離・検証

メンテナンス版、2026-09-01確認。 2019年の原文は空ではなく、Windowsのcompactを再帰実行するコマンドが1つ掲載されていました。その全文を末尾に保存しています。メンテナンス版では、タイトルから連想されるもう1つの意味、つまりドライブ内のZIP/TARアーカイブを探す作業を扱いますが、ドライブ全体の無差別な展開は勧めません。

最初に「展開」の意味を区別する

互いに無関係な2つの操作が、どちらも展開や解凍と呼ばれます。

  • NTFSファイルシステム圧縮: Windowsが通常のファイルを圧縮して保存し、アプリケーションは普段どおり開きます。Microsoftの`compact`ドキュメントでは、/uがこの属性を解除し、/sがディレクトリ以下を再帰処理すると説明されています。
  • アーカイブ展開: ZIPやTAR系コンテナー内の名前付きメンバーを、宛先ディレクトリへ書き出します。Pythonは`zipfile``tarfile`でこれらを扱います。

compactはZIPを開かず、ZIPの展開はNTFS圧縮属性を変えません。アーカイブに残したコマンドは、現在のドライブ上のファイルシステム保存方式を再帰的に変更するものです。タイトルが「すべてのアーカイブを解凍」に見えるという理由だけで実行しないでください。NTFS圧縮を扱う場合も、まず確認済みの1ディレクトリで状態を照会して試します。ボリューム全体の変更は大量の空き容量を消費し、保護されたOSファイルにも遭遇します。

ドライブ全体の無差別な展開が危険な理由

アーカイブは無害なファイルの袋ではありません。展開前に、少なくとも次を検討します。

  • 絶対パス、..要素、Windowsドライブパス、移植できない名前
  • シンボリックリンク、ハードリンク、デバイス、FIFOなどの特殊TARメンバー
  • 重複名、ファイルとディレクトリの競合、大文字小文字だけが異なる衝突
  • 巨大な出力や極端な展開比率を宣言するアーカイブ
  • 無人処理をパスワード管理問題へ変える暗号化メンバー
  • 2つのアーカイブが同じ宛先名へ書き込むこと
  • 一部のファイルを作成した後に発生する失敗

Python公式ドキュメントは、展開前にアーカイブを検査するよう明示的に警告しています。TARのdata抽出フィルターでも、すべてのサービス拒否や悪意ある入力を防げません。安全な処理では、先に棚卸しし、新しい隔離ディレクトリへ展開し、資源上限を適用し、受け入れる前に結果を検証します。

保守的なワークフロー

  1. ソースドライブを変更せず、出力とログをスキャン対象ツリーの外へ置きます。
  2. まずアーカイブらしいファイル名を探し、拡張子だけを信じず内容からZIP/TAR形式を確認します。
  3. SHA-256、メンバー数、宣言された非圧縮サイズ、展開比率、すべての拒否理由を記録します。
  4. JSON Linesマニフェストを確認します。problemsがある記録は展開しません。
  5. 毎回、相対パスを1つだけ選んで展開します。
  6. 既存リンクも既存ファイルもない、新しい一時ディレクトリへ展開します。
  7. すべての通常ファイルのパスとサイズを厳密に検証し、状態記録を書いてから、完成したディレクトリを最終位置へ改名します。
  8. 元のアーカイブは残します。削除は、バックアップとアプリケーションレベルの確認後に別途判断する保持方針です。

これは意図的に再帰ワンライナーより遅い処理です。その代わり、部分的失敗、衝突、再試行を観測できます。

Python 3.12+の監査・展開リファレンスツール

次の標準ライブラリだけのスクリプトは、ZIPと読み取り可能なTAR形式を扱います。既定では棚卸しだけを行い、--extractは棚卸し内の1つのアーカイブパスだけを受け付けます。リンクと特殊ファイル、パストラバーサルとWindowsドライブパス、重複名と大文字小文字の衝突、暗号化ZIPメンバー、上限超過のメンバー数・宣言バイト数・展開比率を拒否します。

上限値はポリシー例であり、普遍的な安全値ではありません。信頼できないソースには、さらに低い値を設定してください。低権限アカウントと適切に制限された環境で不信な内容を扱います。このスクリプトはガードレールであり、マルウェアスキャナーやセキュリティ境界ではありません。

<details>
<summary><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 "" 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>

まず棚卸しだけを行い、展開しない

スクリプトをarchive_audit.pyとして保存します。3つのパスを明示し、互いの配下にならないようにします。

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

--extractを付けなければ、展開先ディレクトリは作りません。一般的なZIP/TAR接尾辞を探しますが、形式はzipfile.is_zipfile()またはtarfile.is_tarfile()で確認します。改名された拡張子を信じず、同時にドライブ上の全通常ファイルを高コストで解析することも避けます。

マニフェストは排他的作成モードで開くため、既存ログを黙って上書きしません。アーカイブの相対名、ソースルート情報、ハッシュ、宣言サイズ、予定する宛先名、拒否詳細が入ります。ファイル名やマウントパスが機密なら、ログも非公開で保管してください。

容量、メンバー、拒否理由を確認する

対応アーカイブごとに、少なくとも次を確認します。

  • problems:展開前に空のリストでなければならない
  • sha256:検査したアーカイブの正確なバイト列を識別する
  • file_countunpacked_bytes:宣言された通常ファイルの作業量
  • expansion_ratio:宣言出力をアーカイブサイズで割った値
  • destination:無害化した名前、相対パスハッシュと内容ハッシュの接頭辞
  • 最終サマリーのwalk_errors:スキャンエラーが1つでもあれば棚卸しが不完全なので、安全に展開できない

既定上限は1アーカイブあたり100,000メンバー、宣言出力50 GiB、展開比率200:1です。これらは安全を保証しません。アーカイブヘッダーは悪意を持ち得て、ファイルシステムのメタデータも容量を使い、確認後に別プロセスが空き容量を消費することもあります。そのため展開先では、Pythonの`shutil.disk_usage()`で、宣言出力に10%または64 MiBの大きい方を加えた空き容量も要求します。

確認済みアーカイブを1つだけ展開する

問題のない棚卸し記録から正確なrelative_pathをコピーし、新しいマニフェスト名を指定します。

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'

スクリプトは展開の前後にソースを再ハッシュし、新しい一時ディレクトリで処理し、既存の宛先には決してマージしません。TARには明示的にfilter="data"を指定し、そのAPIがなければ失敗します。その後、全通常出力ファイルのパスとサイズを確認済み棚卸しと比較し、成功してからディレクトリを最終位置へ改名します。

ソースツリーには書き込みません。出力はソース外でなければならないため、展開で生まれたアーカイブが同じスキャンへ戻ることもありません。各相対アーカイブパスに、パスと内容の両方でアドレスされた隔離宛先を割り当てるため、別名のアーカイブが同一バイト列でも衝突しません。

パスワードと暗号化アーカイブ

このツールは暗号化ZIPメンバーを意図的に拒否します。平文パスワードをコマンドライン、スクリプト、JSONLマニフェスト、シェル履歴、プロセス一覧、ソースリポジトリへ入れないでください。まずアーカイブの来歴を確認し、対話入力または承認済みシークレット管理機構から秘密を取得する、確認済みの形式専用ツールを使い、隔離先への展開を維持します。

Pythonのzipfileドキュメントは、復号が極めて遅いと説明しています。さらに、正しいパスワードで復号できても、メンバーパス、展開サイズ、内容の安全性は証明されません。別の暗号化アーカイブ処理にも、同等の棚卸しと検証を組み込んでください。

ログ、冪等性、検証の限界

成功した宛先には.archive-state.jsonが作られ、ソース相対パス、SHA-256、ファイル数、宣言出力バイト数を記録します。同じバイト列を再度展開しても上書きせず、既存ツリーを検証してalready-verifiedと報告します。アーカイブが変わればハッシュ由来の宛先も変わります。既存ディレクトリが不完全、または変更済みならマージを拒否します。

パスとサイズの一致は有用な非破壊構造検査ですが、文書の真正性や実行ファイルの無害性は証明しません。SHA-256は検査対象を識別しますが、発行者を認証しません。発行者の署名や信頼済みチェックサムがあれば別途確認し、ローカルポリシーに従って内容をスキャンし、危険な形式は隔離ビューアーだけで開いてください。展開中にプログラムを実行してはいけません。

対応範囲と既知の制約

このリファレンスツールは意図的に狭い範囲だけを扱います。

  • インストール済みPythonが読める標準ZIPとTARに対応し、一般的なgzip/bzip2/xz圧縮TARも含みます。
  • 単体の.gz.bz2.xz、分割ZIP、RAR、7z、または不足しているPythonオプション圧縮モジュールが必要な形式は記録または失敗し、別の展開器を推測して使いません。
  • 候補探索は一般的な接尾辞から始めるため、無関係な名前を持つ有効なアーカイブは自動検出しません。そのようなファイルは、ドライブ上の全バイト列を調べるのではなく個別に調査します。
  • リンクと特殊TARファイルシステムオブジェクトは再現せず拒否します。
  • 資源上限は危険を減らすだけです。悪意ある入力にはプロセス権限、CPU、メモリ、ファイルシステムクォータ、時間の制限も必要です。

これらの制約は安全機能です。未対応または曖昧な入力は、強力な展開モードを黙って選ぶのではなく、停止して確認すべきです。

使い捨て環境での検証結果

埋め込みスクリプトは、使い捨てのローカルディレクトリでPython 3.12.3を使って構文検査と動作試験を行いました。通常のZIP、A.txta.txtを含むZIP、シンボリックリンクを含むTAR、../escape.txtというメンバーを持つTARを使いました。

  • 棚卸しモードは展開結果を作りませんでした。
  • 通常のZIPは隔離されたハッシュ名ディレクトリへ展開され、パスとサイズの検証を通過しました。
  • 同じ選択を再度実行すると、上書きせずalready-verifiedを返しました。
  • 大文字小文字の衝突、シンボリックリンク、パストラバーサルは展開前に拒否されました。

検証では実ドライブ、運用パス、ネットワーク上のアーカイブを変更していません。

公式一次資料

---

2019年の英語原文アーカイブ(逐語保存)

原エクスポートは空ではありません。以下は2019-06-13に公開・最終更新された表示可能な本文全文です。リポジトリ整形のため、見えない行末空白だけを正規化しました。来歴として保存するものであり、ドライブ全体を変更する推奨手順ではありません。


First move present working directory to root of each drive, then type

C:
compact /u /s /a /q /i

Leave a Reply