Source code for httk.core.digests
"""Compute deterministic SHA-256 digests for files and directory trees.
Hash tree paths and entries in sorted order without following symlinks.
"""
import hashlib
from collections.abc import Callable
from pathlib import Path
__all__ = ["sha256_file", "tree_digest"]
[docs]
def sha256_file(path: Path) -> str:
"""Return the lowercase SHA-256 digest of one regular file.
:param path: Regular file to hash.
:return: Lowercase SHA-256 digest.
"""
digest = hashlib.sha256()
with path.open("rb") as handle:
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
[docs]
def tree_digest(
path: Path,
*,
skip: Callable[[str], bool] | None = None,
exclude: Callable[[str], bool] | None = None,
) -> str:
"""Hash a tree without following symlinks.
*skip* names top-level entries to leave out of the digest entirely.
*exclude* names relative POSIX paths to leave out; excluding a directory
also leaves out its descendants.
:param path: Root directory to hash.
:param skip: Optional predicate for top-level entries to omit.
:param exclude: Optional predicate for relative POSIX paths to omit.
:return: Lowercase SHA-256 digest.
:raises ValueError: If the tree contains a symlink or special file.
"""
digest = hashlib.sha256()
excluded_directories: list[str] = []
for entry in sorted(path.rglob("*"), key=lambda item: item.relative_to(path).as_posix()):
parts = entry.relative_to(path).parts
relative_text = entry.relative_to(path).as_posix()
if skip is not None and parts and skip(parts[0]):
continue
if any(relative_text.startswith(directory + "/") for directory in excluded_directories):
continue
if exclude is not None and exclude(relative_text):
if entry.is_dir():
excluded_directories.append(relative_text)
continue
relative = relative_text.encode()
if entry.is_symlink():
raise ValueError(f"symlink is forbidden in immutable bundle: {entry}")
if entry.is_dir():
digest.update(b"D\0" + relative + b"\0")
elif entry.is_file():
digest.update(b"F\0" + relative + b"\0")
with entry.open("rb") as handle:
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
else:
raise ValueError(f"special file is forbidden in immutable bundle: {entry}")
return digest.hexdigest()