"""Content-hash helpers for integrity / tamper verification. A tiny, dependency-free module so the *same* hashing code backs two controls: - **ADR-0011** (manual-upload safety gate) records ``sha256`` in an upload's provenance at validation time. - **ADR-0010** (dataset integrity on load) will reuse :func:`verify_sha256` to refuse a tampered file at ``resolve_to_local_path`` time. Streamed so a multi-GB h5ad never has to be read into memory at once. """ from __future__ import annotations import hashlib from pathlib import Path # 4 MiB — matches the download chunk size used in src/core/data_io.py. _DEFAULT_CHUNK = 4 * 1024 * 1024 def compute_sha256(path: str | Path, chunk_size: int = _DEFAULT_CHUNK) -> str: """Return the hex SHA-256 of the file at ``path``, read in chunks. Raises ``FileNotFoundError`` if the path does not exist (callers that stage a file should have materialized it first). """ h = hashlib.sha256() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(chunk_size), b""): h.update(chunk) return h.hexdigest() def verify_sha256(path: str | Path, expected: str, chunk_size: int = _DEFAULT_CHUNK) -> bool: """Return True iff the file at ``path`` hashes to ``expected`` (case-insensitive). A missing/empty ``expected`` returns False — an absent baseline is not a pass. (ADR-0010 handles "no baseline recorded" as a distinct, explicit case at its call site; this helper only answers "does it match".) """ if not expected: return False return compute_sha256(path, chunk_size).lower() == str(expected).strip().lower()