Technology · PRACTICAL GUIDE
GitHub ZIP has LFS stubs? Verify the exact payload before discarding a clone
A successful ZIP download can still contain LFS pointers. Use a trusted commit's pointer to check one exact archive member's uncompressed bytes, and keep a one-file match separate from a complete repository backup.

GitHub source ZIPs contain Git LFS pointers by default. Repository administrators can opt to include the large objects, but committed tracking rules and the location of the LFS store still matter. A download that opens successfully is therefore not proof that its large-file payloads are present.
This guide checks a narrower, useful question: does one exact file in this ZIP match the ordinary LFS pointer committed at the version you intended? The read-only Python verifier compares uncompressed size and SHA-256, reports stubs and mismatches, and leaves both inputs intact. Even MATCH is not a declaration that you can discard the entire clone.
1. Separate archive policy from a broken clone
GitHub's Settings → Archives option can include Git LFS objects in generated source archives. The tracking rules must be committed in .gitattributes. Files served by an external LFS server configured in .lfsconfig are not included by this option.
Those are repository-side archive conditions. Installing LFS on your machine or fetching an object into a clone does not rewrite an already downloaded ZIP. A custom uploaded release asset is also a different artifact from GitHub's automatically generated source snapshot.
Keep the existing clone and original ZIP while investigating. The source snapshot does not preserve the entire Git history, and this single-member test says nothing about uncommitted files or other versions you may need.
2. Fix the intended version and the exact member
Record the full intended commit ID and the repository-relative path, such as assets/model.bin. A moving branch name or a later version's working-tree file is a poor reference for an older ZIP. If you cannot establish the intended commit, the result remains unverified.
List the ZIP without extracting it: python -m zipfile -l source.zip. Copy the exact member name, including the archive's top-level prefix, for example project-commit/assets/model.bin. Do not assume the member's prefix is identical to the repository's path.
Freeze your input copies while auditing. The verifier does not resolve renamed paths, choose between duplicate names or infer which tag the archive represents. An exact-byte match is useful only when the reference itself belongs to the version and file you intended.
3. Read the committed pointer, not the checked-out payload
Run the following preparation inside the preserved clone. Replace FULL_COMMIT, the repository path and the output destination; the destination's parent must exist and the file must be new. Typed git cat-file blob reads the committed raw blob. The helper waits for Git to succeed before creating the reference and uses exclusive binary output, so it refuses to overwrite an existing file.
```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) ```
Then run git lfs pointer --check --strict --file=/outside-clone/expected.pointer and inspect the exit code. A valid canonical pointer returns 0; a non-pointer returns 1 and a noncanonical pointer under strict checking returns 2. A failure is a reason to inspect the reference, not to edit it until it passes.
The reference must be the ordinary three-line v1 form: version URL, oid sha256 with its full lowercase digest, and size in bytes, with LF endings. This verifier deliberately excludes extension-bearing and legacy forms. An empty committed blob is not an ordinary three-line reference for this tool.
4. Run a bounded one-file audit
Save the complete code as lfs_zip_audit.py. Run python lfs_zip_audit.py /outside-clone/expected.pointer source.zip "project-commit/assets/model.bin". This reads an exact member without extracting it, hashes the uncompressed stream and compares both byte length and SHA-256 with the reference.
The default per-file read budget is 2 GiB. A larger file is INCONCLUSIVE unless you deliberately choose a suitable --max-bytes value and have the resources to read it. The 100,000-member limit is a scope bound after the ZIP directory is parsed, not a claim that the program is a sandbox for hostile archives.
```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()) ```
There are no network calls, downloads, checkout operations, source writes or deletion commands in the verifier. It prints a JSON result to stdout. If you save that report, choose a new destination outside the source inputs and keep private paths or object identities out of public support posts.
5. Interpret all outcomes before taking action
MATCH: the member's uncompressed size and digest match the trusted pointer. This proves the selected payload's byte identity within the stated scope. It does not prove the entire archive is complete, the reference was authentic, or the file is safe to run.
POINTER_ONLY: the member is the same ordinary pointer, rather than the expected payload. OTHER_POINTER: it names a different object. MISSING_MEMBER: the exact name is absent; first recheck the archive prefix and intended version.
SIZE_MISMATCH and DIGEST_MISMATCH keep length and content failures separate. INCONCLUSIVE covers unsupported references, duplicate names, links, encryption, read-budget limits and ZIP read errors. It is not a zero-error success or an instruction to force extraction.
Result | What is established | Next safe step MATCH | One selected member matches the trusted OID and size | Test the file in its intended application and expand coverage separately POINTER_ONLY | This ZIP still carries the expected stub | Review archive inclusion policy or obtain the exact payload through the normal authorized LFS route OTHER_POINTER | A different object is referenced | Reconcile commit, path and archive version before fetching MISSING_MEMBER | Exact member name was not found | Recheck prefix/version; do not silently substitute a same-named file SIZE_MISMATCH / DIGEST_MISMATCH | Bytes do not match the reference | Preserve both inputs and resolve identity or integrity failure INCONCLUSIVE | No supported unambiguous result | Resolve the reported limit; retain the inputs and clone
6. Why size alone and pointer-looking text are insufficient
Two payloads can have the same byte length and different contents. A filename and size are therefore not substitutes for the pointer's SHA-256 identity. The verifier reports an equal-size wrong payload as DIGEST_MISMATCH.
Conversely, a payload can itself contain pointer-like text. The verifier checks the expected size and digest before classifying a small text member as a stub, so a literal pointer-text payload with the correct expected identity still receives MATCH.
LFS extensions can transform stored bytes during clean and smudge. In that case the main OID can identify transformed storage bytes, not the final working-tree bytes. This ordinary-pointer tool stops on extensions rather than declaring a valid transformed file corrupt.
7. Know what was actually tested
The exact 4,500-byte script with SHA-256 cad7c90e22c622e8e65d7d50d2a6cb7c119718978e8f00caa4e5523aa8e9a460 passed 36 synthetic assertions on Python 3.12.14/Linux. Tests covered correct bytes, pointer-only and other-pointer cases, size and digest failures, Unicode members, wrong prefixes, duplicate names, input/member links, unsupported references, budgets, CRC corruption and encrypted-member refusal.
Additional local preparation checks verified raw committed-pointer extraction, exclusive reference-file creation, refusal to overwrite and a canonical-pointer check with Git LFS 3.4.1. Inputs remained byte-identical across the read-only audits. These were temporary synthetic fixtures, not tests of your repository, cloud quota or archive settings.
For a real handoff, retain the intended commit, repository path, exact member name, reference digest/size, actual digest/size, audit status and the application-level open test. Treat incomplete coverage or a different environment as a remaining verification task.
8. Keep one-file verification separate from backup completion
A verified model or media file is one useful result. Before removing any clone, separately account for all required files, Git history and refs, submodules, local changes and versions outside this snapshot. A source ZIP and a clone serve different preservation scopes.
If the ZIP contains only pointers, the official archive setting may explain why. If the object is unavailable through the normal LFS route, keep any known-good local copy and resolve access or availability with the repository owner. Repeatedly redownloading the same pointer-only snapshot does not recover its payload.
Frequently asked questions
Does enabling Include Git LFS objects fix my old ZIP? No. Evaluate a newly generated archive and its applicable tracking/store conditions. The already downloaded ZIP's bytes are unchanged.
Will git lfs fetch change the files inside an existing ZIP? No. Fetching into a clone and verifying an archive member are separate operations.
Can I use a pointer copied from another branch? Only if you independently establish that it names the exact payload intended here. Prefer the pointer from the full selected commit and record that identity.
Does one MATCH let me delete the clone? No. It proves one member's bytes, not history, other LFS objects, local work or a complete recovery plan.
What if my pointer has extension lines? Stop this verifier. Extension-aware verification needs the applicable transformations and their identities; do not strip the lines to force a match.
