|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Validate the local-only immutable QSL DeploymentBundle v1 contract.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import argparse |
| 7 | +import hashlib |
| 8 | +import json |
| 9 | +import math |
| 10 | +import re |
| 11 | +import sys |
| 12 | +from datetime import datetime |
| 13 | +from pathlib import Path |
| 14 | +from typing import Any, Mapping |
| 15 | + |
| 16 | +SCHEMA_ID = "qsl.deployment_bundle.v1" |
| 17 | +_DIGEST_ALGORITHM = "sha256" |
| 18 | +_IDENTITY_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") |
| 19 | +_REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$") |
| 20 | +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") |
| 21 | +_TIMESTAMP_PATTERN = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") |
| 22 | +_FORBIDDEN_KEY_PATTERN = re.compile( |
| 23 | + r"credential|secret|token|password|cookie|jwt|private|access[_-]?key|broker|account|order|capital|" |
| 24 | + r"activation|apply|runtime|configured|live[_-]?ready|promotion|matched|fill", |
| 25 | + re.IGNORECASE, |
| 26 | +) |
| 27 | +_URL_PATTERN = re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE) |
| 28 | +_REQUIRED_FIELDS = { |
| 29 | + "schema", |
| 30 | + "bundle_id", |
| 31 | + "created_at", |
| 32 | + "digest_algorithm", |
| 33 | + "strategy", |
| 34 | + "profile", |
| 35 | + "config", |
| 36 | + "evidence", |
| 37 | + "target", |
| 38 | + "dependencies", |
| 39 | + "bundle_sha256", |
| 40 | +} |
| 41 | +_REQUIRED_ARTIFACT_FIELDS = {"id", "revision", "artifact_sha256"} |
| 42 | + |
| 43 | + |
| 44 | +class BundleValidationError(ValueError): |
| 45 | + """Raised when an input is not a valid immutable deployment bundle.""" |
| 46 | + |
| 47 | + |
| 48 | +def _fail(message: str) -> None: |
| 49 | + raise BundleValidationError(message) |
| 50 | + |
| 51 | + |
| 52 | +def _reject_non_finite_or_null(value: Any, path: str = "bundle") -> None: |
| 53 | + if value is None: |
| 54 | + _fail(f"{path} must not be null") |
| 55 | + if isinstance(value, float) and not math.isfinite(value): |
| 56 | + _fail(f"{path} contains a non-finite number") |
| 57 | + if isinstance(value, Mapping): |
| 58 | + for key, child in value.items(): |
| 59 | + if not isinstance(key, str): |
| 60 | + _fail(f"{path} contains a non-string key") |
| 61 | + _reject_non_finite_or_null(child, f"{path}.{key}") |
| 62 | + elif isinstance(value, list): |
| 63 | + for index, child in enumerate(value): |
| 64 | + _reject_non_finite_or_null(child, f"{path}[{index}]") |
| 65 | + |
| 66 | + |
| 67 | +def _reject_forbidden_material(value: Any, path: str = "bundle") -> None: |
| 68 | + if isinstance(value, Mapping): |
| 69 | + for key, child in value.items(): |
| 70 | + if _FORBIDDEN_KEY_PATTERN.search(key): |
| 71 | + _fail(f"{path}.{key} is forbidden in a deployment bundle") |
| 72 | + _reject_forbidden_material(child, f"{path}.{key}") |
| 73 | + elif isinstance(value, list): |
| 74 | + for index, child in enumerate(value): |
| 75 | + _reject_forbidden_material(child, f"{path}[{index}]") |
| 76 | + elif isinstance(value, str) and _URL_PATTERN.search(value): |
| 77 | + _fail(f"{path} contains a forbidden URL") |
| 78 | + |
| 79 | + |
| 80 | +def _expect_object(value: Any, path: str) -> Mapping[str, Any]: |
| 81 | + if not isinstance(value, Mapping): |
| 82 | + _fail(f"{path} must be an object") |
| 83 | + return value |
| 84 | + |
| 85 | + |
| 86 | +def _expect_exact_keys(value: Mapping[str, Any], expected: set[str], path: str) -> None: |
| 87 | + missing = sorted(expected - set(value)) |
| 88 | + unknown = sorted(set(value) - expected) |
| 89 | + if missing: |
| 90 | + _fail(f"{path} missing required field(s): {', '.join(missing)}") |
| 91 | + if unknown: |
| 92 | + _fail(f"{path} has unknown field(s): {', '.join(unknown)}") |
| 93 | + |
| 94 | + |
| 95 | +def _expect_identity(value: Any, path: str) -> str: |
| 96 | + if not isinstance(value, str) or not _IDENTITY_PATTERN.fullmatch(value): |
| 97 | + _fail(f"{path} must be a lowercase immutable identity") |
| 98 | + return value |
| 99 | + |
| 100 | + |
| 101 | +def _expect_revision(value: Any, path: str) -> str: |
| 102 | + if not isinstance(value, str) or not _REVISION_PATTERN.fullmatch(value): |
| 103 | + _fail(f"{path} must be a lowercase 40-character revision") |
| 104 | + return value |
| 105 | + |
| 106 | + |
| 107 | +def _expect_sha256(value: Any, path: str) -> str: |
| 108 | + if not isinstance(value, str) or not _SHA256_PATTERN.fullmatch(value): |
| 109 | + _fail(f"{path} must be a lowercase SHA-256 digest") |
| 110 | + return value |
| 111 | + |
| 112 | + |
| 113 | +def _expect_timestamp(value: Any) -> str: |
| 114 | + if not isinstance(value, str) or not _TIMESTAMP_PATTERN.fullmatch(value): |
| 115 | + _fail("created_at must be an RFC3339 UTC timestamp with whole seconds") |
| 116 | + try: |
| 117 | + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") |
| 118 | + except ValueError as exc: |
| 119 | + raise BundleValidationError("created_at must be a valid calendar timestamp") from exc |
| 120 | + return value |
| 121 | + |
| 122 | + |
| 123 | +def _validate_artifact_identity(value: Any, path: str, *, strategy: bool = False) -> Mapping[str, Any]: |
| 124 | + identity = _expect_object(value, path) |
| 125 | + expected = _REQUIRED_ARTIFACT_FIELDS | ({"source_id"} if strategy else set()) |
| 126 | + _expect_exact_keys(identity, expected, path) |
| 127 | + _expect_identity(identity["id"], f"{path}.id") |
| 128 | + if strategy: |
| 129 | + _expect_identity(identity["source_id"], f"{path}.source_id") |
| 130 | + _expect_revision(identity["revision"], f"{path}.revision") |
| 131 | + _expect_sha256(identity["artifact_sha256"], f"{path}.artifact_sha256") |
| 132 | + return identity |
| 133 | + |
| 134 | + |
| 135 | +def _validate_shape(bundle: Any) -> Mapping[str, Any]: |
| 136 | + _reject_non_finite_or_null(bundle) |
| 137 | + _reject_forbidden_material(bundle) |
| 138 | + root = _expect_object(bundle, "bundle") |
| 139 | + _expect_exact_keys(root, _REQUIRED_FIELDS, "bundle") |
| 140 | + if root["schema"] != SCHEMA_ID: |
| 141 | + _fail(f"schema must be {SCHEMA_ID}") |
| 142 | + _expect_identity(root["bundle_id"], "bundle_id") |
| 143 | + _expect_timestamp(root["created_at"]) |
| 144 | + if root["digest_algorithm"] != _DIGEST_ALGORITHM: |
| 145 | + _fail("digest_algorithm must be sha256") |
| 146 | + strategy = _validate_artifact_identity(root["strategy"], "strategy", strategy=True) |
| 147 | + _validate_artifact_identity(root["profile"], "profile") |
| 148 | + _validate_artifact_identity(root["config"], "config") |
| 149 | + _validate_artifact_identity(root["evidence"], "evidence") |
| 150 | + target = _expect_object(root["target"], "target") |
| 151 | + _expect_exact_keys(target, {"id", "platform_id"}, "target") |
| 152 | + _expect_identity(target["id"], "target.id") |
| 153 | + _expect_identity(target["platform_id"], "target.platform_id") |
| 154 | + dependencies = _expect_object(root["dependencies"], "dependencies") |
| 155 | + _expect_exact_keys(dependencies, {"qpk", "strategy", "pipeline", "platform"}, "dependencies") |
| 156 | + for name in ("qpk", "strategy", "pipeline", "platform"): |
| 157 | + _validate_artifact_identity(dependencies[name], f"dependencies.{name}") |
| 158 | + if strategy["source_id"] != dependencies["strategy"]["id"]: |
| 159 | + _fail("strategy.source_id must match dependencies.strategy.id") |
| 160 | + if strategy["revision"] != dependencies["strategy"]["revision"]: |
| 161 | + _fail("strategy.revision must match dependencies.strategy.revision") |
| 162 | + if target["platform_id"] != dependencies["platform"]["id"]: |
| 163 | + _fail("target.platform_id must match dependencies.platform.id") |
| 164 | + _expect_sha256(root["bundle_sha256"], "bundle_sha256") |
| 165 | + return root |
| 166 | + |
| 167 | + |
| 168 | +def canonical_json(bundle: Mapping[str, Any]) -> str: |
| 169 | + """Return the deterministic JSON representation with only the self hash omitted.""" |
| 170 | + if not isinstance(bundle, Mapping): |
| 171 | + _fail("bundle must be an object") |
| 172 | + content = dict(bundle) |
| 173 | + content.pop("bundle_sha256", None) |
| 174 | + try: |
| 175 | + return json.dumps(content, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) |
| 176 | + except (TypeError, ValueError) as exc: |
| 177 | + raise BundleValidationError("bundle cannot be represented as canonical JSON") from exc |
| 178 | + |
| 179 | + |
| 180 | +def calculate_bundle_sha256(bundle: Mapping[str, Any]) -> str: |
| 181 | + return hashlib.sha256(canonical_json(bundle).encode("utf-8")).hexdigest() |
| 182 | + |
| 183 | + |
| 184 | +def validate_bundle(bundle: Any) -> Mapping[str, Any]: |
| 185 | + """Fail closed unless the exact immutable content matches its declared digest.""" |
| 186 | + root = _validate_shape(bundle) |
| 187 | + expected = calculate_bundle_sha256(root) |
| 188 | + if root["bundle_sha256"] != expected: |
| 189 | + _fail("bundle_sha256 mismatch") |
| 190 | + return root |
| 191 | + |
| 192 | + |
| 193 | +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: |
| 194 | + result: dict[str, Any] = {} |
| 195 | + for key, value in pairs: |
| 196 | + if key in result: |
| 197 | + _fail(f"duplicate JSON key: {key}") |
| 198 | + result[key] = value |
| 199 | + return result |
| 200 | + |
| 201 | + |
| 202 | +def parse_bundle_json(text: str) -> Mapping[str, Any]: |
| 203 | + try: |
| 204 | + value = json.loads(text, object_pairs_hook=_reject_duplicate_pairs, parse_constant=lambda _: _fail("non-finite JSON value")) |
| 205 | + except json.JSONDecodeError as exc: |
| 206 | + raise BundleValidationError("invalid JSON") from exc |
| 207 | + return validate_bundle(value) |
| 208 | + |
| 209 | + |
| 210 | +def main(argv: list[str] | None = None) -> int: |
| 211 | + parser = argparse.ArgumentParser(description=__doc__) |
| 212 | + parser.add_argument("--input", type=Path, required=True, help="immutable bundle JSON to validate locally") |
| 213 | + args = parser.parse_args(argv) |
| 214 | + try: |
| 215 | + bundle = parse_bundle_json(args.input.read_text(encoding="utf-8")) |
| 216 | + except (OSError, BundleValidationError) as exc: |
| 217 | + print(f"deployment bundle validation failed: {exc}", file=sys.stderr) |
| 218 | + return 1 |
| 219 | + print(json.dumps({"bundle_sha256": bundle["bundle_sha256"], "schema": bundle["schema"]}, sort_keys=True)) |
| 220 | + return 0 |
| 221 | + |
| 222 | + |
| 223 | +if __name__ == "__main__": |
| 224 | + raise SystemExit(main()) |
0 commit comments