File size: 7,319 Bytes
5fb5c7d 3014b7f 5fb5c7d 3014b7f 5fb5c7d 3014b7f 5fb5c7d c3b49d6 5fb5c7d 3014b7f c3b49d6 5fb5c7d 3014b7f 5fb5c7d 3014b7f c3b49d6 3014b7f c3b49d6 3014b7f c3b49d6 3014b7f c3b49d6 3014b7f c3b49d6 3014b7f 2d632b5 c3b49d6 2d632b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | """Content-hash helpers for integrity / tamper verification.
One dependency-free module backs two security controls so the *same* hashing
code is shared rather than duplicated:
- **ADR-0011** (manual-upload safety gate) records ``sha256`` in an upload's
provenance at validation time β :func:`compute_sha256` / :func:`verify_sha256`.
- **ADR-0010** (dataset integrity on load) refuses a tampered dataset at
``resolve_to_local_path`` time β :func:`verify_file`, which looks up the
manifest's recorded baseline via the registry (:func:`expected_sha256_for_url`)
and raises :class:`IntegrityError` on mismatch.
The registry recorded an expected SHA-256 for each dataset's hosted source file
(``integrity.sha256`` in the manifest). The load-time check verifies a
materialized file against that baseline *before* it is parsed or cached, so a
silently-altered file with the same shape β which
``dataset_validate_manifest_against_data`` cannot catch β is refused rather than
analysed. That is a Layer-1-style *refusal* (cf. ADR-0002): a hash mismatch on
restricted data is a stop, not a caution. Verification is enforced only where a
baseline exists; datasets without one load unverified (trust-on-first-use until
pre-staging anchors an OHSU-controlled copy β ADR-0007 Phase 4).
All hashing is streamed so a multi-GB h5ad is never 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
# ---------------------------------------------------------------------------
# Low-level primitives (ADR-0011 upload gate + ADR-0010 on-load verify)
# ---------------------------------------------------------------------------
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's :func:`verify_file` 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()
# ---------------------------------------------------------------------------
# On-load verification (ADR-0010): manifest-baselined refusal
# ---------------------------------------------------------------------------
class IntegrityError(RuntimeError):
"""Raised when a loaded file's SHA-256 does not match the manifest baseline.
Refuses the load: the file is never handed to the analysis tools or the cache.
"""
def __init__(
self,
url: str,
expected: str,
actual: str,
dataset_id: str | None = None,
) -> None:
self.url = url
self.expected = expected
self.actual = actual
self.dataset_id = dataset_id
name = f" for dataset '{dataset_id}'" if dataset_id else ""
super().__init__(
f"Integrity check FAILED{name}: content of {url} does not match the "
f"recorded baseline. Expected sha256={expected}, got sha256={actual}. "
f"Refusing to load β the file may have been modified or replaced "
f"(ADR-0010)."
)
def _lookup_from_registry(url: str) -> tuple[str | None, str | None]:
"""Return ``(expected_sha256, dataset_id)`` for a source URL from the registry.
Iterates the installed ``biodata_registry`` manifests and asks each for the
hash matching ``url`` (via ``DatasetManifest.expected_sha256_for_url``). Any
import/registry failure degrades to ``(None, None)`` so a missing or broken
registry never blocks a load β it just means "no baseline".
"""
try:
from biodata_registry import get_registry
from biodata_registry.manifest_schema import DatasetManifest
except Exception:
return None, None
try:
registry = get_registry()
for dataset_id in registry.list():
raw = registry.get(dataset_id)
if not raw:
continue
try:
manifest = DatasetManifest.from_dict(raw)
except Exception:
continue
# expected_sha256_for_url is only present on biodata-registry >= 0.1.9;
# getattr keeps this working against an older pinned wheel (β no baseline).
lookup = getattr(manifest, "expected_sha256_for_url", None)
if lookup is None:
continue
expected = lookup(url)
if expected:
return expected, dataset_id
except Exception:
return None, None
return None, None
def expected_sha256_for_url(url: str) -> str | None:
"""Return the recorded SHA-256 baseline for a source URL, or None if unbaselined."""
expected, _ = _lookup_from_registry(url)
return expected
def verify_file(local_path: str | Path, url: str, *, dataset_id: str | None = None) -> None:
"""Verify a materialized file against its manifest SHA-256 baseline.
No-op when the URL has no recorded baseline (returns silently). Raises
:class:`IntegrityError` on mismatch. ``dataset_id`` is looked up from the
registry when not supplied, purely to enrich the error message.
"""
expected, found_id = _lookup_from_registry(url)
if not expected:
return
actual = compute_sha256(local_path)
if actual.lower() != str(expected).strip().lower():
raise IntegrityError(
url=url,
expected=expected,
actual=actual,
dataset_id=dataset_id or found_id,
)
def verify_bytes(data: bytes, url: str, *, dataset_id: str | None = None) -> None:
"""Verify an in-memory download against its manifest SHA-256 baseline.
The bytes counterpart to :func:`verify_file`, for loaders that hold a source
in memory rather than on disk (e.g. ``load_geo_series_matrix_lines``, which
reads the raw β still-compressed β series-matrix response before parsing).
``data`` must be the source bytes exactly as served (pre-decompression), so
it matches how the baseline was recorded from the same URL.
No-op when the URL has no recorded baseline (returns silently). Raises
:class:`IntegrityError` on mismatch.
"""
expected, found_id = _lookup_from_registry(url)
if not expected:
return
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != str(expected).strip().lower():
raise IntegrityError(
url=url,
expected=expected,
actual=actual,
dataset_id=dataset_id or found_id,
)
|