| """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 |
|
|
| |
| _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() |
|
|