|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import hashlib |
| 4 | +import re |
4 | 5 | from pathlib import Path |
5 | 6 | from typing import Any |
6 | 7 |
|
|
62 | 63 | "live_pool_legacy": "live_pool_legacy.json", |
63 | 64 | } |
64 | 65 | EXPECTED_ARTIFACT_CONTRACT_VERSION = "crypto_live_pool_rotation.live_pool.v1" |
| 66 | +REQUIRED_RUNTIME_EVIDENCE_IDENTITY_FIELDS = ( |
| 67 | + "strategy_profile", |
| 68 | + "mode", |
| 69 | + "source_revision", |
| 70 | + "input_timestamp", |
| 71 | + "artifact_contract", |
| 72 | + "artifact_version", |
| 73 | + "artifacts", |
| 74 | +) |
65 | 75 |
|
66 | 76 |
|
67 | 77 | def _sha256_file(path: Path) -> str: |
@@ -162,6 +172,72 @@ def _normalize_source_project(value: Any, field_label: str, errors: list[str]) - |
162 | 172 | return normalized |
163 | 173 |
|
164 | 174 |
|
| 175 | +def _is_sha256(value: Any) -> bool: |
| 176 | + return isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value.strip())) |
| 177 | + |
| 178 | + |
| 179 | +def _validate_runtime_evidence_identity( |
| 180 | + manifest: dict[str, Any], |
| 181 | + artifact_manifest: dict[str, Any], |
| 182 | + *, |
| 183 | + live_pool_mode: str, |
| 184 | + live_pool_version: str, |
| 185 | + errors: list[str], |
| 186 | +) -> None: |
| 187 | + label = "release_manifest.json runtime_evidence_identity" |
| 188 | + identity = manifest.get("runtime_evidence_identity") |
| 189 | + if not isinstance(identity, dict): |
| 190 | + errors.append(f"{label} must be an object") |
| 191 | + return |
| 192 | + |
| 193 | + _append_missing_fields(identity, REQUIRED_RUNTIME_EVIDENCE_IDENTITY_FIELDS, errors, label) |
| 194 | + if str(identity.get("strategy_profile", "")).strip() != str( |
| 195 | + artifact_manifest.get("strategy_profile", "") |
| 196 | + ).strip(): |
| 197 | + errors.append(f"{label} strategy_profile does not match artifact_manifest.json") |
| 198 | + if str(identity.get("mode", "")).strip() != live_pool_mode: |
| 199 | + errors.append(f"{label} mode does not match live_pool.json") |
| 200 | + if not re.fullmatch(r"[0-9a-f]{40}", str(identity.get("source_revision", "")).strip()): |
| 201 | + errors.append(f"{label} source_revision must be a 40-character lowercase git SHA") |
| 202 | + |
| 203 | + input_timestamp = identity.get("input_timestamp") |
| 204 | + try: |
| 205 | + timestamp = pd.Timestamp(input_timestamp) |
| 206 | + except Exception: |
| 207 | + timestamp = None |
| 208 | + if not isinstance(input_timestamp, str) or timestamp is None or pd.isna(timestamp) or timestamp.tzinfo is None: |
| 209 | + errors.append(f"{label} input_timestamp must be a timezone-aware timestamp") |
| 210 | + |
| 211 | + if str(identity.get("artifact_contract", "")).strip() != str( |
| 212 | + artifact_manifest.get("contract_version", "") |
| 213 | + ).strip(): |
| 214 | + errors.append(f"{label} artifact_contract does not match artifact_manifest.json") |
| 215 | + if str(identity.get("artifact_version", "")).strip() != live_pool_version: |
| 216 | + errors.append(f"{label} artifact_version does not match live_pool.json version") |
| 217 | + |
| 218 | + identity_artifacts = identity.get("artifacts") |
| 219 | + artifact_manifest_artifacts = artifact_manifest.get("artifacts") |
| 220 | + if not isinstance(identity_artifacts, dict): |
| 221 | + errors.append(f"{label} artifacts must be an object") |
| 222 | + return |
| 223 | + if not isinstance(artifact_manifest_artifacts, dict): |
| 224 | + return |
| 225 | + for artifact_name in REQUIRED_ARTIFACT_MANIFEST_ARTIFACTS: |
| 226 | + identity_entry = identity_artifacts.get(artifact_name) |
| 227 | + artifact_entry = artifact_manifest_artifacts.get(artifact_name) |
| 228 | + if not isinstance(identity_entry, dict): |
| 229 | + errors.append(f"{label} artifacts.{artifact_name} must be an object") |
| 230 | + continue |
| 231 | + identity_sha = identity_entry.get("sha256") |
| 232 | + if not _is_sha256(identity_sha): |
| 233 | + errors.append(f"{label} artifacts.{artifact_name}.sha256 must be a SHA-256 digest") |
| 234 | + continue |
| 235 | + if not isinstance(artifact_entry, dict) or identity_sha.strip() != str(artifact_entry.get("sha256", "")).strip(): |
| 236 | + errors.append( |
| 237 | + f"{label} artifacts.{artifact_name}.sha256 does not match artifact_manifest.json" |
| 238 | + ) |
| 239 | + |
| 240 | + |
165 | 241 | def _coerce_selected_flag(series: pd.Series) -> pd.Series: |
166 | 242 | if pd.api.types.is_bool_dtype(series): |
167 | 243 | return series.fillna(False) |
@@ -221,6 +297,7 @@ def validate_release_outputs( |
221 | 297 | max_age_days: int | None = None, |
222 | 298 | require_manifest: bool = False, |
223 | 299 | require_artifact_manifest: bool = False, |
| 300 | + require_runtime_evidence_identity: bool = False, |
224 | 301 | require_freshness: bool = False, |
225 | 302 | ) -> dict[str, Any]: |
226 | 303 | output_path = Path(output_dir) |
@@ -567,6 +644,15 @@ def validate_release_outputs( |
567 | 644 | elif expected_sha != _sha256_file(resolved_path): |
568 | 645 | errors.append(f"artifact_manifest.json artifacts.{artifact_name}.sha256 does not match file content") |
569 | 646 |
|
| 647 | + if require_runtime_evidence_identity and manifest_present and artifact_manifest_present: |
| 648 | + _validate_runtime_evidence_identity( |
| 649 | + manifest, |
| 650 | + artifact_manifest, |
| 651 | + live_pool_mode=live_pool_mode, |
| 652 | + live_pool_version=live_pool_version, |
| 653 | + errors=errors, |
| 654 | + ) |
| 655 | + |
570 | 656 | age_days: int | None = None |
571 | 657 | if live_pool_as_of_ts is not None: |
572 | 658 | if reference_date is None: |
|
0 commit comments