テクノロジー · 実用ガイド

GitHub ZIP に LFS ポインターだけ?クローンを手放す前に実体を確認

ダウンロード成功だけでは実体の存在は証明できません。信頼するコミットのポインターで ZIP 内の一メンバーの非圧縮バイトを照合し、一ファイルの一致と完全なバックアップを区別します。

オリジナルの概念図:信頼するコミット済みLFSポインターのSHA-256とバイト数をZIPの正確な一メンバーと比較。
オリジナルの概念イラスト:非圧縮バイト数とSHA-256の両方を比較し、ポインター、不一致、判定不能を区別します。 Watch at Ease

GitHub のソース ZIP は既定で Git LFS ポインターを含みます。管理者は大容量オブジェクトを含める設定にできますが、コミット済み追跡ルールと LFS 保存先にも条件があります。ZIP が正常に開くことだけでは実体の存在を証明できません。

ここでは限定した実用的な問いを検査します。この ZIP の正確な一ファイルは、意図したバージョンにコミットされた通常の LFS ポインターと一致するでしょうか。読み取り専用 Python ツールは非圧縮サイズと SHA-256 を比較し、ポインターや不一致を報告して入力を保持します。MATCH でもクローン全体を手放せるという判定にはなりません。

1. アーカイブ設定とクローンの不具合を切り分ける

GitHub の Settings → Archives では、生成するソースアーカイブに Git LFS オブジェクトを含めるよう設定できます。.gitattributes の追跡ルールがコミットされている必要があります。.lfsconfig で指定された外部 LFS サーバーのファイルは、この設定の対象に含まれません。

これはリポジトリ側のアーカイブ条件です。手元で LFS をインストールしたり、クローンにオブジェクトを取得したりしても、ダウンロード済みの ZIP は書き換わりません。個別にアップロードされたリリース添付ファイルも、GitHub が自動生成するソーススナップショットとは別の成果物です。

調査中は既存のクローンと元の ZIP を保持してください。ソーススナップショットは Git の全履歴を保存しません。この単一メンバーの検査では、未コミットのファイルや必要な別バージョンも確認できません。

2. 対象バージョンとメンバー名を確定する

対象の完全なコミット ID と、assets/model.bin などのリポジトリ内相対パスを記録します。移動するブランチ名や後のバージョンの作業ツリーファイルは、古い ZIP の基準には適しません。対象コミットを確定できなければ、結果は未検証のままです。

展開せずに python -m zipfile -l source.zip で一覧を表示します。project-commit/assets/model.bin のように、最上位の接頭辞を含む正確なメンバー名をコピーしてください。ZIP 内の接頭辞がリポジトリのパスと同じだと思い込まないでください。

検査中は入力コピーを変更しないでください。この検証プログラムは改名されたパスを解決せず、重複名から一つを選ばず、ZIP のタグも推測しません。基準ファイルが意図したバージョンとパスのものである場合にだけ、バイトの一致に意味があります。

3. チェックアウト済み実体ではなくコミット済みポインターを読む

保持しているクローン内で次の準備を行います。FULL_COMMIT、リポジトリ内パス、出力先を置き換えてください。出力先の親ディレクトリは存在し、出力ファイルは新規である必要があります。型を指定した git cat-file blob は、コミット済み blob の生の内容を読みます。この補助コードは Git の成功を待ってから基準ファイルを作成し、排他的なバイナリ出力を使うため既存ファイルを上書きしません。

```python import subprocess from pathlib import Path blob = subprocess.check_output([ "git", "cat-file", "blob", "FULL_COMMIT:assets/model.bin" ]) with Path("/outside-clone/expected.pointer").open("xb") as output: output.write(blob) ```

次に git lfs pointer --check --strict --file=/outside-clone/expected.pointer を実行し、終了コードを確認します。正しい正規形式のポインターは 0、ポインター以外は 1、strict 検査で非正規形式と判定されたポインターは 2 です。失敗した場合は基準を調べてください。通過するように編集するべきではありません。

基準は通常の v1 の3行形式に限ります。version URL、完全な小文字のダイジェストを持つ oid sha256、バイト数の size を LF 改行で記述します。このプログラムは拡張行や旧形式を意図的に対象外としています。空のコミット済み blob も、このツールの通常の3行基準にはなりません。

4. 範囲を限定した単一ファイル検査を実行する

コード全体を lfs_zip_audit.py として保存します。python lfs_zip_audit.py /outside-clone/expected.pointer source.zip "project-commit/assets/model.bin" を実行してください。展開せずに正確なメンバーを読み、非圧縮ストリームをハッシュ化して、そのバイト数と SHA-256 を基準と比較します。

既定のファイルごとの読み取り上限は 2 GiB です。それ以上のファイルは、必要な資源を確保して適切な --max-bytes を明示的に選ばない限り INCONCLUSIVE になります。100,000 メンバーの制限は ZIP ディレクトリ解析後の対象範囲制限であり、悪意あるアーカイブ用のサンドボックスを意味しません。

```python """Read-only single-member ZIP audit against a trusted canonical v1 LFS pointer.""" import argparse import hashlib import json import re import stat import zipfile import zlib from pathlib import Path POINTER = re.compile(rb'version https://git-lfs.github.com/spec/v1\noid sha256:([0-9a-f]{64})\nsize (0|[1-9][0-9]*)\n') def parse_pointer(data): if len(data) >= 1024: raise ValueError('reference is outside the pointer-size scope') match = POINTER.fullmatch(data) if not match: raise ValueError('requires an ordinary canonical three-line v1 pointer; extensions, legacy and noncanonical forms are outside scope') return match[1].decode('ascii'), int(match[2]) def regular(path): path = Path(path) if not stat.S_ISREG(path.lstat().st_mode): raise ValueError('input must be a regular file, not a symlink or directory') return path def audit(pointer_path, archive_path, member_name, max_bytes=2*1024**3): if max_bytes < 0: raise ValueError('max_bytes must be nonnegative') pointer_path, archive_path = regular(pointer_path), regular(archive_path) with pointer_path.open('rb') as stream: reference = stream.read(1024) expected_sha, expected_size = parse_pointer(reference) if expected_size > max_bytes: raise ValueError('expected payload exceeds the selected read budget') parts = member_name.split('/') if not member_name or '\\' in member_name or '\x00' in member_name or any(p in ('', '.', '..') for p in parts): raise ValueError('use the exact safe slash-separated member name including its archive prefix') with zipfile.ZipFile(archive_path) as archive: infos = archive.infolist() if len(infos) > 100000: raise ValueError('too many ZIP members for this audit scope') matches = [i for i in infos if i.filename == member_name] if not matches: return {'status':'MISSING_MEMBER','member':member_name} if len(matches) != 1: raise ValueError('duplicate exact member name; no unambiguous selection') info = matches[0] mode = stat.S_IFMT(info.external_attr >> 16) if info.is_dir() or mode not in (0, stat.S_IFREG): raise ValueError('member must be a regular file, not a link or directory') if info.flag_bits & 1: raise ValueError('encrypted ZIP members are outside scope') if info.file_size > max_bytes: raise ValueError('ZIP member exceeds the selected read budget') digest, size, prefix = hashlib.sha256(), 0, bytearray() with archive.open(info, 'r') as stream: while True: block = stream.read(1024*1024) if not block: break size += len(block) if size > max_bytes: raise ValueError('stream exceeded the selected read budget') digest.update(block) if len(prefix) < 1024: prefix.extend(block[:1024-len(prefix)]) actual_sha = digest.hexdigest() if size == expected_size and actual_sha == expected_sha: status = 'MATCH' else: try: found = parse_pointer(bytes(prefix)) if size < 1024 else None except ValueError: found = None if found == (expected_sha, expected_size): status = 'POINTER_ONLY' elif found: status = 'OTHER_POINTER' else: status = 'SIZE_MISMATCH' if size != expected_size else 'DIGEST_MISMATCH' return {'status':status,'member':member_name,'expectedBytes':expected_size,'actualBytes':size,'expectedSha256':expected_sha,'actualSha256':actual_sha} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('pointer');parser.add_argument('zip');parser.add_argument('member') parser.add_argument('--max-bytes',type=int,default=2*1024**3) args = parser.parse_args() try: result = audit(args.pointer,args.zip,args.member,args.max_bytes) except (OSError,ValueError,RuntimeError,EOFError,zlib.error,zipfile.BadZipFile,NotImplementedError) as exc: result = {'status':'INCONCLUSIVE','reason':str(exc)} print(json.dumps(result,ensure_ascii=False)) return 0 if result['status']=='MATCH' else 2 if result['status']=='INCONCLUSIVE' else 1 if __name__=='__main__': raise SystemExit(main()) ```

検証プログラムにはネットワーク通信、ダウンロード、チェックアウト、入力への書き込み、削除コマンドはありません。JSON 結果を標準出力に表示します。報告を保存する場合は入力以外の新しい出力先を選び、公開のサポート投稿に非公開パスやオブジェクト識別情報を載せないでください。

5. 操作前に全結果の意味を確認する

MATCH は、メンバーの非圧縮サイズとダイジェストが信頼したポインターに一致したことを示します。指定範囲内で対象実体のバイト同一性を確認できます。アーカイブ全体の完全性、基準の真正性、ファイルの実行安全性は証明しません。

POINTER_ONLY は、期待する実体ではなく同じ通常ポインターが入っている状態です。OTHER_POINTER は別のオブジェクトを指します。MISSING_MEMBER は正確な名前が見つからない状態です。まずアーカイブの接頭辞と対象バージョンを再確認します。

SIZE_MISMATCH と DIGEST_MISMATCH は長さと内容の不一致を区別します。INCONCLUSIVE は未対応の基準、重複名、リンク、暗号化、読み取り上限、ZIP 読み取りエラーなどです。エラーゼロの成功でも、強制展開の指示でもありません。

結果 | 確認できること | 次の安全な手順 MATCH | 対象メンバー一つが信頼した OID とサイズに一致 | 利用先アプリで確認し、別途検証範囲を広げる POINTER_ONLY | ZIP には期待するポインターが残っている | アーカイブ設定を確認するか、通常の権限ある LFS 経路で正しい実体を得る OTHER_POINTER | 別のオブジェクトを参照している | 取得前にコミット、パス、ZIP のバージョンを照合する MISSING_MEMBER | 正確なメンバー名が見つからない | 接頭辞とバージョンを確認し、同名ファイルで黙って代用しない SIZE_MISMATCH / DIGEST_MISMATCH | バイトが基準と一致しない | 両入力を保持し、同一性または整合性の問題を解決する INCONCLUSIVE | 対応範囲内で一意な判定ができない | 報告された制限を解決し、入力とクローンを保持する

6. サイズやポインター風の文字列だけでは不十分な理由

二つの実体が同じバイト数でも、内容は異なる可能性があります。ファイル名とサイズはポインターの SHA-256 による識別の代わりになりません。同じサイズの誤った実体は DIGEST_MISMATCH と判定します。

逆に、実体そのものがポインターに似たテキストを含むこともあります。このプログラムは短いテキストをポインターと分類する前に、期待するサイズとダイジェストを確認します。そのため、正しい識別情報を持つ文字通りのポインターテキスト実体も MATCH になります。

LFS 拡張は clean と smudge の際に保存バイトを変換できます。その場合、主要 OID が示すのは変換済み保存バイトで、最終的な作業ツリーのバイトとは限りません。この通常ポインター用ツールは拡張に遭遇したら停止し、正しい変換済みファイルを破損扱いしません。

7. 実際に検証した範囲を理解する

SHA-256 cad7c90e22c622e8e65d7d50d2a6cb7c119718978e8f00caa4e5523aa8e9a460 の正確な 4,500 バイトのスクリプトは、Python 3.12.14/Linux で36件の合成テストのアサーションを通過しました。正しい実体、ポインターのみ、別ポインター、サイズとダイジェストの不一致、Unicode 名、誤った接頭辞、重複名、入力とメンバーのリンク、未対応基準、上限、CRC 破損、暗号化メンバーの拒否を検査しました。

追加のローカル準備検査では、コミット済みポインターの生の抽出、新規基準ファイルの排他的作成、上書き拒否、Git LFS 3.4.1 の正規形式検査を確認しました。読み取り専用検査の前後で入力バイトは一致しました。一時的な合成データによる検査であり、利用者のリポジトリ、クラウド容量、アーカイブ設定のテストではありません。

実際の引き継ぎでは対象コミット、リポジトリ内パス、正確なメンバー名、基準と実体のダイジェストとサイズ、検査結果、アプリでの読み込みテストを記録してください。未検証の範囲や別環境は、残る確認作業として扱います。

8. 一ファイルの検証とバックアップ完了を切り分ける

モデルやメディア一つの検証は有用な成果です。クローンを削除する前に、必要な全ファイル、Git 履歴と refs、サブモジュール、ローカル変更、このスナップショット外のバージョンを別途確認してください。ソース ZIP とクローンでは保存範囲が異なります。

ZIP にポインターしかない場合、公式のアーカイブ設定が原因を説明できることがあります。通常の LFS 経路からオブジェクトを入手できなければ、正常と分かっているローカルコピーを保持し、所有者とアクセス権や利用可能性を解決してください。同じポインターのみの ZIP を何度取得しても実体は復元できません。

よくある質問

Include Git LFS objects を有効にすると古い ZIP は直りますか? いいえ。新しく生成したアーカイブと適用される追跡ルール・保存先条件を検査してください。ダウンロード済み ZIP のバイトは変わりません。

git lfs fetch は既存 ZIP の中を変更しますか? いいえ。クローンへの取得とアーカイブメンバーの検証は別の操作です。

別ブランチからコピーしたポインターを使えますか? ここで意図する正確な実体を指すと独立に確認できた場合だけです。対象の完全なコミットから取得し、その識別情報を記録する方法を優先してください。

一つの MATCH が出ればクローンを削除できますか? いいえ。一メンバーのバイトを証明するだけで、履歴、他の LFS オブジェクト、ローカル作業、完全な復旧計画を証明しません。

ポインターに拡張行がある場合は? この検証プログラムを止めてください。拡張対応の検証には適用される変換と識別情報が必要です。一致を強制するために行を削除してはいけません。

オリジナルの概念図:一ファイルのMATCHは履歴、refs、サブモジュール、ローカル作業を検証しません。
オリジナルの概念イラスト:より広い保存範囲を確認するまでクローンを保持してください。MATCHは対象メンバーのバイトだけを証明します。 Watch at Ease