一. 前言:檔名一樣,不代表內容一樣 #
你下載了一個 8 GB 的模型檔。 檔名正確、大小看起來也差不多,程式卻在載入到 97% 時報錯。 這時候最有用的問題不是「要不要再下載一次」,而是:
我手上的每一個 byte,真的和發布者提供的檔案一樣嗎? 雜湊摘要(hash digest)就是用來回答這個問題的工具。 它會把任意長度的 bytes 映射成固定長度的指紋。 輸入只要改一點,摘要通常就會完全不同。 Python 標準庫的
hashlib可以處理:
- 計算字串或 bytes 的 SHA-256
- 串流讀取大型檔案,不把整包資料塞進記憶體
- 用
file_digest()簡化檔案摘要 - 建立一份可重複驗證的 checksum manifest
- 用
compare_digest()比對外部提供的摘要 - 明確區分「完整性」和「來源可信度」 今天拍拍君會做出一個小型檔案驗證工具。 重點不是背 API,而是把邊界條件也處理好:演算法、路徑、格式、錯誤和驗證結果都要清楚。
二. 安裝:標準庫內建,先確認 Python 版本 #
hashlib 是 Python 標準庫,不需要另外安裝。
import hashlib
本文的基本範例可在現代 Python 3 執行。
其中 hashlib.file_digest() 是 Python 3.11 加入的便利函式。
先看看環境提供哪些演算法:
import hashlib
print(sorted(hashlib.algorithms_guaranteed))
print("sha256" in hashlib.algorithms_available)
兩個集合的意義不同:
algorithms_guaranteed:Python 保證跨平台可用的名稱algorithms_available:目前這個直譯器與 OpenSSL 實際提供的名稱 如果工具要跨機器執行,優先選 guaranteed 集合裡的演算法。 不要在自己的 Mac 上看到某個名字,就假設所有 CI runner 都一定有。
三. 第一個 SHA-256:雜湊吃的是 bytes #
最短的例子是:
from hashlib import sha256
payload = b"Daily Pypy"
digest = sha256(payload)
print(digest.digest())
print(digest.hexdigest())
digest() 回傳原始 bytes。
hexdigest() 回傳容易顯示、複製與寫進文字檔的十六進位字串。
SHA-256 的摘要是 32 bytes,所以 hex 形式會有 64 個字元:
from hashlib import sha256
h = sha256(b"Daily Pypy")
assert len(h.digest()) == 32
assert len(h.hexdigest()) == 64
如果手上是字串,要先決定編碼:
from hashlib import sha256
message = "拍拍君"
encoded = message.encode("utf-8")
print(sha256(encoded).hexdigest())
下面這段則會失敗:
# sha256("拍拍君") # TypeError: Strings must be encoded before hashing
這不是麻煩人的限制。
同一段文字可能用 UTF-8、UTF-16 或其他編碼變成不同 bytes。
hashlib 要你先把規則說清楚,摘要才有可重現性。
四. update():分段餵資料,結果仍然一樣
#
Hash object 可以多次 update():
from hashlib import sha256
one_shot = sha256(b"Daily Pypy").hexdigest()
streamed = sha256()
streamed.update(b"Daily ")
streamed.update(b"Pypy")
assert streamed.hexdigest() == one_shot
多次 update 的結果,等於把 bytes 串起來後一次計算。 這個特性就是大型檔案串流處理的基礎。 如果要摘要多個欄位,不要只把它們直接相加;應使用明確序列化格式、長度前綴或固定 schema。 摘要忠實反映輸入 bytes,但資料邊界仍是應用程式的責任。
五. 演算法怎麼選:預設從 SHA-256 開始 #
hashlib 提供很多演算法,但不表示每一個都適合新設計。
| 演算法 | 摘要長度 | 新的完整性流程 | 備註 |
|---|---|---|---|
| SHA-256 | 256 bits | 推薦預設 | 相容性廣、容易和 CLI 工具交換 |
| SHA-512 | 512 bits | 可用 | 摘要更長 |
| SHA3-256 | 256 bits | 可用 | SHA-3 家族 |
| BLAKE2b | 1–64 bytes | 可用 | 可調輸出長度,也支援 keyed mode |
| MD5 | 128 bits | 不建議 | 已知碰撞弱點,只適合相容舊 checksum |
| SHA-1 | 160 bits | 不建議 | 已知碰撞弱點,不應用於新安全流程 |
| 一般檔案發布流程,用 SHA-256 就很合理: |
import hashlib
def make_hasher(name: str = "sha256"):
allowed = {"sha256", "sha512", "sha3_256", "blake2b"}
if name not in allowed:
raise ValueError(f"unsupported digest algorithm: {name}")
return hashlib.new(name)
白名單比直接接受任意字串更容易維護。
它也讓 manifest 的政策穩定,不會因為某台機器多提供一個 OpenSSL alias 就悄悄改變。
usedforsecurity=False 不是「把弱演算法變安全」的開關。
它只是在受限制環境中,明確表示某個舊演算法不是用於安全情境。
新設計不要靠它替 MD5 或 SHA-1 找理由。
六. 串流計算大型檔案:固定記憶體就夠了 #
最直覺但不理想的寫法是:
from hashlib import sha256
from pathlib import Path
data = Path("model.bin").read_bytes()
print(sha256(data).hexdigest())
小檔案沒問題,但 8 GB 檔案會先要求大約 8 GB 記憶體。 其實雜湊只需要一塊一塊讀:
from __future__ import annotations
import hashlib
from pathlib import Path
CHUNK_SIZE = 1024 * 1024
def hash_file(path: Path, algorithm: str = "sha256") -> str:
hasher = hashlib.new(algorithm)
with path.open("rb") as source:
while chunk := source.read(CHUNK_SIZE):
hasher.update(chunk)
return hasher.hexdigest()
print(hash_file(Path("model.bin")))
這段程式不論檔案是 20 MB 或 20 GB,主要 buffer 都維持在 1 MiB 左右。 Chunk size 不會改變摘要:
from pathlib import Path
target = Path("model.bin")
assert hash_file(target) == hash_file(target, "sha256")
它主要影響 I/O 呼叫次數與吞吐量。 通常選 256 KiB 到數 MiB,再用自己的儲存裝置量測即可。 不要為了追求一個神奇 chunk size,把清楚的程式改成迷宮。
七. file_digest():Python 3.11+ 的便利入口
#
Python 3.11 起,可以把開啟的 binary file object 交給 file_digest():
from __future__ import annotations
import hashlib
from pathlib import Path
def hash_file_311(path: Path) -> str:
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
它讓意圖很直接:對這個檔案計算指定摘要。 仍然要注意幾件事:
- 檔案必須以 binary reading mode 開啟。
- 呼叫後不要依賴 file object 的目前位置。
- 如果要支援 Python 3.10,保留上一節的
update()迴圈。 - 摘要完成後,檔案仍可能被其他程序修改。 最後一點是 TOCTOU(檢查時間與使用時間不同)問題。 如果安全流程要求「驗證的就是接下來要處理的內容」,應該在同一個受控流程中保留檔案描述符,或把驗證通過的內容移入不可變的暫存區。
八. 驗證外部摘要:先驗格式,再安全比對 #
假設發布者提供了一串 SHA-256:
9f86d081884c7d659a2feaa0c55ad015...
我們先做嚴格格式驗證:
from __future__ import annotations
import re
SHA256_RE = re.compile(r"[0-9a-fA-F]{64}\Z")
def normalize_sha256(value: str) -> str:
candidate = value.strip().lower()
if not SHA256_RE.fullmatch(candidate):
raise ValueError("expected exactly 64 hexadecimal characters")
return candidate
接著使用 hmac.compare_digest():
from __future__ import annotations
import hmac
from pathlib import Path
def verify_file(path: Path, expected: str) -> bool:
normalized = normalize_sha256(expected)
actual = hash_file(path)
return hmac.compare_digest(actual, normalized)
compare_digest() 避免一般字串比對依內容提早結束,適合安全敏感的摘要或 MAC 比對。
兩邊要使用相同型別;這裡統一成 ASCII hex 字串。
這和 Python secrets 實戰 的角色不同。
secrets 負責產生不可預測 token;本篇的摘要是從既有檔案計算出來的確定值。
兩者都會用到安全比對,但用途完全不一樣。
九. 建立 checksum manifest:一批檔案,一份規格 #
單一檔案可以手動比對。 如果 release 有模型、設定、詞表與索引檔,最好建立 manifest。 先定義 JSON 格式:
{
"version": 1,
"algorithm": "sha256",
"files": {
"config.json": "<64-char hex>",
"model.bin": "<64-char hex>"
}
}
產生 manifest:
from __future__ import annotations
import json
from pathlib import Path
def build_manifest(root: Path, names: list[str]) -> dict[str, object]:
files: dict[str, str] = {}
for name in sorted(names):
relative = Path(name)
if relative.is_absolute() or ".." in relative.parts:
raise ValueError(f"unsafe relative path: {name}")
target = root / relative
if not target.is_file():
raise FileNotFoundError(target)
files[relative.as_posix()] = hash_file(target)
return {
"version": 1,
"algorithm": "sha256",
"files": files,
}
root = Path("release")
manifest = build_manifest(root, ["config.json", "model.bin"])
Path("checksums.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
排序有兩個好處:
- 同一批檔案每次輸出的順序一致
- Git diff 和 code review 比較容易讀
路徑檢查則避免 manifest 指向
../private.key或絕對路徑。 Checksum 工具也在處理輸入邊界,不只是跑一個 hash function。
十. 驗證 manifest:分清 missing、mismatch 與 ok #
驗證工具不應只回傳一個模糊的 False。
維運時需要知道是檔案不存在,還是內容不同。
from __future__ import annotations
import hmac
from pathlib import Path
def verify_manifest(
root: Path,
manifest: dict[str, object],
) -> dict[str, str]:
if manifest.get("version") != 1:
raise ValueError("unsupported manifest version")
if manifest.get("algorithm") != "sha256":
raise ValueError("unsupported manifest algorithm")
entries = manifest.get("files")
if not isinstance(entries, dict):
raise ValueError("manifest files must be an object")
results: dict[str, str] = {}
for name, raw_expected in entries.items():
if not isinstance(name, str) or not isinstance(raw_expected, str):
raise ValueError("manifest entries must map strings to strings")
relative = Path(name)
if relative.is_absolute() or ".." in relative.parts:
results[name] = "unsafe-path"
continue
target = root / relative
if not target.is_file():
results[name] = "missing"
continue
expected = normalize_sha256(raw_expected)
actual = hash_file(target)
results[name] = (
"ok" if hmac.compare_digest(actual, expected) else "mismatch"
)
return results
載入並印出結果:
import json
from pathlib import Path
manifest = json.loads(Path("checksums.json").read_text(encoding="utf-8"))
results = verify_manifest(Path("release"), manifest)
for name, status in results.items():
print(f"{status:12} {name}")
if any(status != "ok" for status in results.values()):
raise SystemExit(1)
非零 exit code 很重要。 它讓 CI、部署腳本與 shell pipeline 可以真正阻止損壞的 artifact 繼續往下走。
十一. 測試:一個 byte 改變就必須失敗 #
完整性工具最重要的測試,不是「成功時印得漂不漂亮」。 而是損壞、缺檔與危險路徑是否真的被擋住。
from pathlib import Path
def test_round_trip(tmp_path: Path) -> None:
(tmp_path / "model.bin").write_bytes(b"PYPY-v1")
manifest = build_manifest(tmp_path, ["model.bin"])
assert verify_manifest(tmp_path, manifest) == {"model.bin": "ok"}
def test_modified_file_is_rejected(tmp_path: Path) -> None:
target = tmp_path / "model.bin"
target.write_bytes(b"PYPY-v1")
manifest = build_manifest(tmp_path, ["model.bin"])
target.write_bytes(b"PYPY-v2")
assert verify_manifest(tmp_path, manifest) == {"model.bin": "mismatch"}
def test_missing_file_is_reported(tmp_path: Path) -> None:
manifest = {
"version": 1,
"algorithm": "sha256",
"files": {"missing.bin": "0" * 64},
}
assert verify_manifest(tmp_path, manifest) == {"missing.bin": "missing"}
def test_parent_path_is_rejected(tmp_path: Path) -> None:
manifest = {
"version": 1,
"algorithm": "sha256",
"files": {"../secret.txt": "0" * 64},
}
assert verify_manifest(tmp_path, manifest) == {
"../secret.txt": "unsafe-path"
}
如果使用 symlink,還要先定義政策:
- 跟隨連結並驗證目標內容?
- 完全拒絕 symlink?
- 只允許解析後仍位於 root 內的連結? 沒有唯一答案,但不能讓行為靠意外決定。 面對不可信 manifest 時,最保守的工具通常直接拒絕 symlink。
十二. 常見陷阱:摘要不是萬能保證 #
1. Hash 不會證明發布者身分 #
如果攻擊者能同時替換檔案和網站上的 checksum,兩者仍然會一致。 Hash 能檢查內容有沒有變,但不能單獨證明 checksum 是誰發布的。 需要來源驗證時,應搭配:
- HTTPS 傳輸
- 數位簽章
- 可信任的 release channel
- 簽章金鑰與輪替政策
2. 一般 hash 不適合直接存密碼 #
不要把 sha256(password) 當成密碼儲存方案。
密碼需要專門設計、可調成本、帶 salt 的 password hashing/KDF。
hashlib 雖然也提供 PBKDF2 與 scrypt,但那是另一個完整主題。
本篇的目標是檔案完整性,不是密碼資料庫設計。
3. MD5/SHA-1 舊 checksum 要標示風險 #
有些舊資料集只提供 MD5。 你可以為相容性計算它,但不要把「比對成功」寫成強安全保證。 最好同時產生新的 SHA-256 manifest,逐步淘汰舊格式。
4. Metadata 不在檔案摘要裡 #
一般檔案 hash 只涵蓋檔案內容。 檔名、權限、owner、mtime 不會自動包含在摘要裡。 如果部署流程也要驗證這些資訊,必須把它們明確放進已簽章的 manifest。
5. 驗證前後檔案可能改變 #
多程序環境中,通過驗證的路徑可能立刻被替換。
高安全需求下,要設計原子發布、唯讀 artifact store 或 descriptor-based workflow。
別把一次 True 當成宇宙永久保固。
十三. 實務檢查清單 #
交付檔案完整性流程前,拍拍君會確認:
- 明確固定演算法,預設使用 SHA-256
- 大檔案用串流或
file_digest(),不整包讀入記憶體 - 外部 digest 先檢查長度與 hex 格式
- 安全敏感比對使用
compare_digest() - Manifest 有版本、演算法與固定路徑格式
- 拒絕絕對路徑與
..traversal - 清楚區分 missing、mismatch、unsafe-path 與 ok
- 失敗時 CLI 回傳非零 exit code
- 測試至少包含單 byte 修改、缺檔與危險路徑
- 需要來源可信度時,另外加入數位簽章
結語 #
hashlib 的 API 很小,但可靠的完整性流程不只是一行 sha256()。
真正重要的是:
- 先把文字編碼、資料邊界和演算法定清楚。
- 大檔案用
update()或file_digest()串流處理。 - 對外部摘要做嚴格格式驗證,再安全比對。
- 多檔案用有版本的 manifest,並把路徑視為不可信輸入。
- 記住 hash 驗證內容,簽章才負責證明來源。 下次模型、備份或 release artifact 壞掉時,不用盯著檔名猜人生。 讓 bytes 自己出示指紋,拍拍君比較相信證據。🔍