From 56b9565742dc92ba750546b2015019e255f15fe8 Mon Sep 17 00:00:00 2001 From: yuqei Date: Sun, 20 Sep 2026 13:31:14 +0800 Subject: [PATCH] release: harden RC-04 Linux artifact chain --- yuqei-infra/README.md | 8 + yuqei-infra/compose/.env.production.example | 1 + .../docker-compose.production.offline.yml | 48 + .../compose/docker-compose.production.yml | 11 + yuqei-infra/deploy/README.md | 53 +- yuqei-infra/deploy/preflight-production.py | 2 + yuqei-infra/deploy/release-package.py | 2686 +++++++++++++++++ .../deploy/verify-production-compose.py | 481 +++ yuqei-infra/docker/contract-web.Dockerfile | 19 +- .../linux-release-security-policy.v1.json | 309 ++ .../scripts/build-installable-release.py | 977 ++++++ .../build-release-candidate-manifest.py | 96 +- .../build-release-worktree-ownership.py | 39 +- yuqei-infra/scripts/linux_release_common.py | 1926 ++++++++++++ .../scripts/run-linux-release-build.py | 1721 +++++++++++ ...installable_release_assembler_hardening.py | 168 ++ .../tests/test_linux_release_build_tools.py | 1869 ++++++++++++ .../test_production_compose_merge_gate.py | 362 +++ .../test_production_compose_preflight.py | 168 ++ .../tests/test_production_release_tools.py | 1 + .../test_release_candidate_manifest_tools.py | 93 + .../tests/test_release_package_executor.py | 1745 +++++++++++ 22 files changed, 12767 insertions(+), 16 deletions(-) create mode 100644 yuqei-infra/compose/docker-compose.production.offline.yml create mode 100644 yuqei-infra/deploy/release-package.py create mode 100644 yuqei-infra/deploy/verify-production-compose.py create mode 100644 yuqei-infra/release/linux-release-security-policy.v1.json create mode 100644 yuqei-infra/scripts/build-installable-release.py create mode 100644 yuqei-infra/scripts/linux_release_common.py create mode 100644 yuqei-infra/scripts/run-linux-release-build.py create mode 100644 yuqei-infra/tests/test_installable_release_assembler_hardening.py create mode 100644 yuqei-infra/tests/test_linux_release_build_tools.py create mode 100644 yuqei-infra/tests/test_production_compose_merge_gate.py create mode 100644 yuqei-infra/tests/test_production_compose_preflight.py create mode 100644 yuqei-infra/tests/test_release_package_executor.py diff --git a/yuqei-infra/README.md b/yuqei-infra/README.md index e4af54f7..5bd7e79d 100644 --- a/yuqei-infra/README.md +++ b/yuqei-infra/README.md @@ -358,6 +358,14 @@ Keep `CONTRACT_API_AI_PLATFORM_TIMEOUT_SECONDS` at least five seconds above `AI_ Start from `yuqei-infra\compose\.env.production.example` and replace every `CHANGE_ME` value before deployment. +The production inventory requires immutable digest references for Contract API, +Contract Web, AI Platform API, and AI Worker. The production override clears the +shared development `build` definitions with the Compose Specification `!reset` +tag. On a host whose images were imported offline, append +`compose/docker-compose.production.offline.yml` last so every production service, +including `backend-hosted-web` and `ops` profile services, uses +`pull_policy: never`. See `deploy/README.md` for the complete file order. + Run migrations explicitly before replacing application containers. The 2-second read cache is process-local and is automatically disabled when `CONTRACT_API_UVICORN_WORKERS` is greater than `1`. Use a shared cache with cross-process invalidation before increasing the production worker count if the same list/detail p95 target must be retained. diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index 5fee39e0..e69cd574 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -3,6 +3,7 @@ COMPOSE_PROJECT_NAME=yuqei-v2-production RELEASE_ID=CHANGE_ME_IMMUTABLE_RC_TAG CONTRACT_API_IMAGE=registry.example.com/yuqei/contract-api@sha256:CHANGE_ME_64_HEX_DIGEST +CONTRACT_WEB_IMAGE=registry.example.com/yuqei/contract-web@sha256:CHANGE_ME_64_HEX_DIGEST AI_PLATFORM_API_IMAGE=registry.example.com/yuqei/ai-platform-api@sha256:CHANGE_ME_64_HEX_DIGEST AI_PLATFORM_RELEASE_IMAGE_DIGEST=sha256:CHANGE_ME_64_HEX_DIGEST AI_WORKER_IMAGE=registry.example.com/yuqei/ai-worker@sha256:CHANGE_ME_64_HEX_DIGEST diff --git a/yuqei-infra/compose/docker-compose.production.offline.yml b/yuqei-infra/compose/docker-compose.production.offline.yml new file mode 100644 index 00000000..622c3027 --- /dev/null +++ b/yuqei-infra/compose/docker-compose.production.offline.yml @@ -0,0 +1,48 @@ +services: + postgres: + pull_policy: never + + postgres-init-databases: + pull_policy: never + + redis: + pull_policy: never + + minio: + pull_policy: never + + contract-api-migrate: + pull_policy: never + + contract-api: + pull_policy: never + + contract-review-worker: + pull_policy: never + + contract-playbook-governance-worker: + pull_policy: never + + ai-platform-api-migrate: + pull_policy: never + + ai-platform-prompt-assets-import: + pull_policy: never + + ai-platform-api: + pull_policy: never + + worker: + pull_policy: never + + contract-web: + pull_policy: never + + edge: + pull_policy: never + + contract-files-backup: + pull_policy: never + + minio-backup: + pull_policy: never diff --git a/yuqei-infra/compose/docker-compose.production.yml b/yuqei-infra/compose/docker-compose.production.yml index 47d51821..187a458c 100644 --- a/yuqei-infra/compose/docker-compose.production.yml +++ b/yuqei-infra/compose/docker-compose.production.yml @@ -18,10 +18,12 @@ services: contract-api-migrate: image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always contract-api: image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always # SMTP remains opt-in through docker-compose.production.smtp.yml so disabled mode has no secret-file dependency. environment: @@ -38,20 +40,24 @@ services: contract-review-worker: image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always volumes: - contract-files:/data/contract-files contract-playbook-governance-worker: image: ${CONTRACT_API_IMAGE:?CONTRACT_API_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always ai-platform-api-migrate: image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always ai-platform-prompt-assets-import: image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always # Production startup validates the packaged assets without publishing them. # An approved publication batch must invoke the importer separately with --apply. @@ -59,6 +65,7 @@ services: ai-platform-api: image: ${AI_PLATFORM_API_IMAGE:?AI_PLATFORM_API_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always environment: AI_PLATFORM_SECRET_FILE_ROOT: /run/secrets @@ -80,9 +87,13 @@ services: worker: image: ${AI_WORKER_IMAGE:?AI_WORKER_IMAGE must reference an immutable production image} + build: !reset null pull_policy: always contract-web: + image: ${CONTRACT_WEB_IMAGE:?CONTRACT_WEB_IMAGE must reference an immutable production image} + build: !reset null + pull_policy: always profiles: ["backend-hosted-web"] edge: diff --git a/yuqei-infra/deploy/README.md b/yuqei-infra/deploy/README.md index 0385e316..8d84d9c5 100644 --- a/yuqei-infra/deploy/README.md +++ b/yuqei-infra/deploy/README.md @@ -1,18 +1,21 @@ # V2 production backend deployment -The production backend runs on `192.168.3.90`; Contract Web is deployed separately to Alibaba Cloud. The production compose override therefore disables Contract Web by default and exposes only the HTTPS edge service publicly. +The production backend runs on `192.168.3.90`; Contract Web is deployed separately to Alibaba Cloud. The production compose override therefore keeps Contract Web behind the opt-in `backend-hosted-web` profile and exposes only the HTTPS edge service publicly by default. Its image is still required and digest-pinned so every production profile has one immutable image inventory. ## Files - `../compose/docker-compose.yml`: shared service definitions used by local and smoke environments. -- `../compose/docker-compose.production.yml`: backend-only production image, volume, TLS edge, and backup-helper overrides. +- `../compose/docker-compose.production.yml`: production image, volume, TLS edge, and backup-helper overrides. It uses the Compose Specification `!reset null` tag to remove every application `build` inherited from the shared file. +- `../compose/docker-compose.production.offline.yml`: final, offline-only override that forbids registry pulls for every service that production can enable, including optional profiles. - `../compose/docker-compose.production.smtp.yml`: optional SMTP password secret mount; include only when email delivery mode is `smtp`. - `../compose/.env.production.example`: reviewed variable inventory. The real file belongs at `/etc/yuqei/production.env`, mode `0600`, outside the repository. - `preflight-production.py`: read-only production configuration gate. +- `verify-production-compose.py`: read-only merged-model gate. It renders Docker Compose JSON for online and offline modes across the default, `backend-hosted-web`, `ops`, and combined profile sets. +- `release-package.py`: backend-only release-package verifier and planner. Its production apply entry point remains deliberately closed until the complete infrastructure-image import plan and externally trusted backup/compatibility approvals are available. ## Prepare without changing traffic -1. Create an immutable RC commit and publish the three application images by digest. Pin PostgreSQL/pgvector, Redis, MinIO, mc, Nginx, and the backup helper by digest as well. +1. Create an immutable RC commit and publish the four application images by digest: Contract API, Contract Web, AI Platform API, and AI Worker. Pin PostgreSQL/pgvector, Redis, MinIO, mc, Nginx, and the backup helper by digest as well. 2. Create a new production PostgreSQL/volume set. Do not reuse or rename the smoke database or volumes. 3. Mount an off-host backup filesystem at `BACKUP_HOST_DIR`. 4. Place `tls.crt`, `tls.key`, and the AI provider key in protected host directories. When enabling SMTP, also create the file named by `CONTRACT_API_IDENTITY_SMTP_PASSWORD_FILE_HOST`, mode `0600`. @@ -29,15 +32,11 @@ python3 yuqei-infra/deploy/preflight-production.py \ --artifact-dir /var/lib/yuqei/preflight ``` -7. Validate and pull images without starting services: +7. Validate the merged Compose models, then pull online images without starting services: ```bash -docker compose \ - --project-name yuqei-v2-production \ - --env-file /etc/yuqei/production.env \ - -f yuqei-infra/compose/docker-compose.yml \ - -f yuqei-infra/compose/docker-compose.production.yml \ - config --quiet +python3 yuqei-infra/deploy/verify-production-compose.py \ + --env-file /etc/yuqei/production.env docker compose \ --project-name yuqei-v2-production \ @@ -47,7 +46,39 @@ docker compose \ pull ``` -These commands do not start containers and do not switch traffic. +The merge gate invokes only `docker compose config --format json`; it does not +start, build, pull, or remove anything. The separate `pull` command downloads +the already approved online image references, but does not start containers or +switch traffic. + +Use a current Docker Compose V2 release that implements the Compose Specification +`!reset` tag. A successful `config --quiet` invocation proves only that Compose +can parse and interpolate the files; it does not prove the merged image, build, +or pull-policy content. The JSON merge gate performs those semantic checks. + +For an offline host, import every approved image into the local engine through +the approved artifact-transfer procedure. The JSON merge gate always appends +`docker-compose.production.offline.yml` after the base and production files when +it verifies offline mode. + +Pass `--profile backend-hosted-web` and/or `--profile ops` when those services are +intended. The offline override sets `pull_policy: never` for all services in every +core production profile, so a missing local image fails closed instead of contacting +a registry. A companion overlay that introduces another image-bearing service needs +an explicit offline pull policy of its own before it is approved for this path. Do +not add the offline override to the online `pull` command. + +`release-package.py` validates the current four-application Docker import plan, +including archive size/SHA-256 and the expected config-based image IDs used for +offline Compose overrides. That is not yet a clean-host offline installation +closure: PostgreSQL, the PostgreSQL client, Redis, MinIO, mc, Nginx, and the +backup helper do not all have approved import archives, and the packaged backup +and rollback-compatibility records are only self-hashed rather than externally +signed. Consequently `verify` reports structural validity without apply +eligibility, plans expose the blocking reasons, and the normal `--apply` entry +point fails before creating state or loading an image. Contract Web remains an +identity-bound, separately deployed artifact; this backend executor never claims +that the full cross-host release has been installed or rolled back. The production override also replaces `ai-platform-prompt-assets-import` with the importer's default dry-run command. That Compose service may validate and report the diff --git a/yuqei-infra/deploy/preflight-production.py b/yuqei-infra/deploy/preflight-production.py index 0898bb7d..b673fbb3 100644 --- a/yuqei-infra/deploy/preflight-production.py +++ b/yuqei-infra/deploy/preflight-production.py @@ -124,6 +124,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) "COMPOSE_PROJECT_NAME", "RELEASE_ID", "CONTRACT_API_IMAGE", + "CONTRACT_WEB_IMAGE", "AI_PLATFORM_API_IMAGE", "AI_PLATFORM_RELEASE_IMAGE_DIGEST", "AI_WORKER_IMAGE", @@ -197,6 +198,7 @@ def validate_environment(values: dict[str, str], *, template_mode: bool = False) image_keys = ( "CONTRACT_API_IMAGE", + "CONTRACT_WEB_IMAGE", "AI_PLATFORM_API_IMAGE", "AI_WORKER_IMAGE", "POSTGRES_IMAGE", diff --git a/yuqei-infra/deploy/release-package.py b/yuqei-infra/deploy/release-package.py new file mode 100644 index 00000000..55d0a76d --- /dev/null +++ b/yuqei-infra/deploy/release-package.py @@ -0,0 +1,2686 @@ +#!/usr/bin/env python3 +"""Verify, plan, and explicitly apply one immutable Yuqei backend package. + +The default operation is read-only verification. Install, rollback, and +forward operations are plans unless ``--apply`` and the operation's exact +confirmation string are supplied; those controls are necessary but not +sufficient. Production apply currently fails closed while the declared +release blockers remain unresolved. The executor is deliberately offline: it +never builds or pulls images and it never manages traffic, DNS, volumes, or +database downgrades. Contract Web identity is package-bound but is deployed by +its separate web orchestrator, so no result from this tool claims completion of +the full cross-host release. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import re +import stat +import subprocess +import sys +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Iterator, Mapping + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_FILE = "release-package.json" +BASE_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.yml" +PRODUCTION_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.production.yml" +OFFLINE_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.production.offline.yml" + +PACKAGE_SCHEMA = "yuqei.release-package.v1" +CHECKSUM_SCHEMA = "yuqei.release-package-checksums.v1" +SOURCE_MANIFEST_SCHEMA = "yuqei.release-source-manifest.v1" +IMAGE_IDENTITY_SCHEMA = "yuqei.release-image-identity.v1" +BACKUP_PROOF_SCHEMA = "yuqei.release-backup-proof.v1" +COMPATIBILITY_PROOF_SCHEMA = "yuqei.release-rollback-compatibility.v1" +INSTALLABLE_MANIFEST_SCHEMA = "yuqei.installable-release-manifest.v1" +DOCKER_IMPORT_PLAN_SCHEMA = "yuqei.docker-image-import-plan.v1" +PLAN_SCHEMA = "yuqei.release-package-plan.v1" +RECEIPT_SCHEMA = "yuqei.release-package-receipt.v1" +PENDING_SCHEMA = "yuqei.release-package-pending.v1" +POST_APPLY_SCHEMA = "yuqei.release-post-apply-check.v1" +PREFLIGHT_BINDING_SCHEMA = "yuqei.production-preflight-binding.v1" + +DOCKER_CLI = "/usr/bin/docker" +LOCAL_DOCKER_HOST = "unix:///var/run/docker.sock" +FIXED_DOCKER_HOME = "/var/empty" +REAL_APPLY_BLOCKERS = ( + "infrastructure_docker_import_plan_not_available", + "backup_and_compatibility_external_signatures_not_verified", +) + +CONFIRMATIONS = { + "install": "I_AUTHORIZE_THIS_EXACT_RELEASE_INSTALL", + "rollback": "I_AUTHORIZE_THIS_EXACT_RELEASE_ROLLBACK", + "forward": "I_AUTHORIZE_THIS_EXACT_RELEASE_FORWARD", +} + +APPLICATION_IMAGE_KEYS = ( + "CONTRACT_API_IMAGE", + "CONTRACT_WEB_IMAGE", + "AI_PLATFORM_API_IMAGE", + "AI_WORKER_IMAGE", +) +BACKEND_APPLICATION_IMAGE_KEYS = ( + "CONTRACT_API_IMAGE", + "AI_PLATFORM_API_IMAGE", + "AI_WORKER_IMAGE", +) +INFRASTRUCTURE_IMAGE_KEYS = ( + "POSTGRES_IMAGE", + "POSTGRES_CLIENT_IMAGE", + "REDIS_IMAGE", + "MINIO_IMAGE", + "MINIO_MC_IMAGE", + "NGINX_IMAGE", + "BACKUP_UTILITY_IMAGE", +) +BACKEND_INSTALL_INFRASTRUCTURE_IMAGE_KEYS = ( + "POSTGRES_IMAGE", + "POSTGRES_CLIENT_IMAGE", + "REDIS_IMAGE", + "MINIO_IMAGE", +) +DEPLOYMENT_SCOPE = "backend_only" +SEPARATELY_DEPLOYED_IMAGE_KEYS = ("CONTRACT_WEB_IMAGE",) +RUNTIME_SERVICES = ( + "contract-api", + "contract-review-worker", + "contract-playbook-governance-worker", + "ai-platform-api", + "worker", +) +INSTALL_SERVICES = ( + "postgres", + "postgres-init-databases", + "redis", + "minio", + "contract-api-migrate", + "ai-platform-api-migrate", + *RUNTIME_SERVICES, +) +REQUIRED_ARTIFACTS = ( + "release_manifest", + "checksum_manifest", + "image_identity", + "installable_manifest", +) +OPTIONAL_EVIDENCE_ARTIFACTS = ("backup_proof", "compatibility_proof") + +IMPORT_ROLE_ENV_KEYS = ( + ("contract-web", "CONTRACT_WEB_IMAGE"), + ("contract-api", "CONTRACT_API_IMAGE"), + ("ai-platform-api", "AI_PLATFORM_API_IMAGE"), + ("ai-worker", "AI_WORKER_IMAGE"), +) + +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +COMMIT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") +RELEASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") +IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +APPROVAL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$") +ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +IMAGE_REFERENCE_RE = re.compile( + r"^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$" +) +IMAGE_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +MAX_JSON_BYTES = 4 * 1024 * 1024 +MAX_ENV_BYTES = 1024 * 1024 + +FORBIDDEN_COMMAND_TOKENS = frozenset( + { + "down", + "rm", + "prune", + "build", + "pull", + "push", + "volume", + "volumes", + "downgrade", + "edge", + "dns", + "--volumes", + "-v", + } +) + + +class ReleasePackageError(RuntimeError): + """Raised when a package or requested transition fails closed.""" + + +@dataclass(frozen=True) +class ArtifactPin: + path: str + sha256: str + + +@dataclass(frozen=True) +class ValidatedPackage: + root: Path + package: dict[str, Any] + package_sha256: str + checksum_manifest: dict[str, Any] + checksum_manifest_sha256: str + release_manifest: dict[str, Any] + release_manifest_sha256: str + image_identity: dict[str, Any] + image_identity_sha256: str + installable_manifest: dict[str, Any] + installable_manifest_sha256: str + installable_root: Path + trusted_build_receipt_sha256: str + docker_import_plan: tuple[dict[str, Any], ...] + backup_proof: dict[str, Any] | None + backup_proof_sha256: str | None + compatibility_proof: dict[str, Any] | None + compatibility_proof_sha256: str | None + application_images: dict[str, dict[str, str]] + infrastructure_images: dict[str, dict[str, str]] + rollback_images: dict[str, dict[str, str]] + rollback_release_id: str + + +@dataclass(frozen=True) +class ExecutionOutcome: + payload: dict[str, Any] + exit_code: int = 0 + replayed: bool = False + + +@dataclass(frozen=True) +class RuntimeEvidence: + env_file: Path + env_values: dict[str, str] + env_sha256: str + preflight_summary: dict[str, Any] + compose_gate_summary: dict[str, Any] + daemon_identity: dict[str, str] + + +@dataclass(frozen=True) +class SnapshotInputs: + root: Path + env_file: Path + compose_files: tuple[Path, Path, Path] + docker_archives: dict[str, Path] + manifest: dict[str, Any] + + +_PREFLIGHT_MODULE: Any | None = None +_COMPOSE_GATE_MODULE: Any | None = None +_INSTALLABLE_VALIDATOR_MODULE: Any | None = None + + +def _exec_module_without_bytecode(spec: Any, module: Any) -> None: + """Execute a local validator without mutating its source tree.""" + previous = sys.dont_write_bytecode + sys.dont_write_bytecode = True + try: + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = previous + + +def _load_local_module(filename: str, module_name: str) -> Any: + path = Path(__file__).resolve().with_name(filename) + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ReleasePackageError(f"Cannot load required deployment gate: {filename}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + _exec_module_without_bytecode(spec, module) + except Exception as exc: + sys.modules.pop(module_name, None) + raise ReleasePackageError(f"Cannot load required deployment gate: {filename}") from exc + return module + + +def _load_preflight_module() -> Any: + global _PREFLIGHT_MODULE + if _PREFLIGHT_MODULE is None: + _PREFLIGHT_MODULE = _load_local_module( + "preflight-production.py", + "yuqei_release_package_preflight", + ) + return _PREFLIGHT_MODULE + + +def _load_compose_gate_module() -> Any: + global _COMPOSE_GATE_MODULE + if _COMPOSE_GATE_MODULE is None: + _COMPOSE_GATE_MODULE = _load_local_module( + "verify-production-compose.py", + "yuqei_release_package_compose_gate", + ) + return _COMPOSE_GATE_MODULE + + +def _load_installable_validator_module() -> Any: + global _INSTALLABLE_VALIDATOR_MODULE + if _INSTALLABLE_VALIDATOR_MODULE is None: + path = Path(__file__).resolve().parents[1] / "scripts" / "build-installable-release.py" + spec = importlib.util.spec_from_file_location( + "yuqei_release_package_installable_validator", + path, + ) + if spec is None or spec.loader is None: + raise ReleasePackageError("Cannot load the strict installable manifest validator.") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + _exec_module_without_bytecode(spec, module) + except Exception as exc: + sys.modules.pop(spec.name, None) + raise ReleasePackageError( + "Cannot load the strict installable manifest validator." + ) from exc + _INSTALLABLE_VALIDATOR_MODULE = module + return _INSTALLABLE_VALIDATOR_MODULE + + +def canonical_json_bytes(value: object) -> bytes: + try: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise ReleasePackageError("Value is not canonical JSON.") from exc + + +def canonical_sha256(value: object) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def confirmation_for(operation: str) -> str: + try: + return CONFIRMATIONS[operation] + except KeyError as exc: + raise ReleasePackageError(f"Unsupported write operation: {operation}") from exc + + +def _real_apply_blockers(operation: str) -> list[str]: + blockers = list(REAL_APPLY_BLOCKERS) + if operation == "rollback": + blockers.append("rollback_docker_import_plan_not_available") + return blockers + + +def _reject_json_constant(value: str) -> None: + raise ReleasePackageError(f"Non-finite JSON value is forbidden: {value}") + + +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ReleasePackageError(f"Duplicate JSON key is forbidden: {key}") + result[key] = value + return result + + +def _read_json(path: Path, *, label: str) -> dict[str, Any]: + try: + size = path.stat().st_size + if size <= 0 or size > MAX_JSON_BYTES: + raise ReleasePackageError(f"{label} must be non-empty and no larger than 4 MiB.") + raw = path.read_bytes() + payload = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_reject_duplicate_pairs, + parse_constant=_reject_json_constant, + ) + except ReleasePackageError: + raise + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ReleasePackageError(f"{label} is not readable strict UTF-8 JSON.") from exc + if not isinstance(payload, dict): + raise ReleasePackageError(f"{label} must be a JSON object.") + return payload + + +def _verify_embedded_hash( + payload: Mapping[str, Any], + field: str, + *, + label: str, +) -> None: + expected = payload.get(field) + if not isinstance(expected, str) or not SHA256_RE.fullmatch(expected): + raise ReleasePackageError(f"{label} has no valid {field}.") + unsigned = {key: value for key, value in payload.items() if key != field} + if canonical_sha256(unsigned) != expected: + raise ReleasePackageError(f"{label} canonical payload hash mismatch.") + + +def _absolute_lexical(path: Path) -> Path: + return Path(os.path.abspath(os.fspath(path))) + + +def _reject_symlink_components(path: Path, *, label: str) -> None: + absolute = _absolute_lexical(path) + current = Path(absolute.anchor) + for part in absolute.parts[1:]: + current = current / part + try: + if current.exists() and current.is_symlink(): + raise ReleasePackageError(f"{label} must not contain symbolic links: {current}") + except OSError as exc: + raise ReleasePackageError(f"Unable to inspect {label} path components.") from exc + + +def _validated_directory(path: Path, *, label: str) -> Path: + lexical = _absolute_lexical(path) + _reject_symlink_components(lexical, label=label) + try: + resolved = lexical.resolve(strict=True) + except OSError as exc: + raise ReleasePackageError(f"{label} does not exist.") from exc + if lexical.is_symlink() or not resolved.is_dir(): + raise ReleasePackageError(f"{label} must be a real directory, not a symbolic link.") + return resolved + + +def _validated_file(path: Path, *, label: str, max_bytes: int | None = None) -> Path: + lexical = _absolute_lexical(path) + if not lexical.is_absolute(): # pragma: no cover - os.path.abspath guarantees this. + raise ReleasePackageError(f"{label} must be absolute.") + _reject_symlink_components(lexical, label=label) + try: + resolved = lexical.resolve(strict=True) + size = resolved.stat().st_size + except OSError as exc: + raise ReleasePackageError(f"{label} is unavailable.") from exc + if lexical.is_symlink() or not resolved.is_file(): + raise ReleasePackageError(f"{label} must be a real regular file, not a symbolic link.") + if max_bytes is not None and (size <= 0 or size > max_bytes): + raise ReleasePackageError(f"{label} has an invalid size.") + return resolved + + +def _safe_relative_path(value: object, *, label: str) -> str: + if not isinstance(value, str) or not value or "\\" in value or "\x00" in value: + raise ReleasePackageError(f"{label} must use a non-empty canonical POSIX relative path.") + pure = PurePosixPath(value) + if ( + pure.is_absolute() + or pure.as_posix() != value + or any(part in {"", ".", ".."} for part in pure.parts) + ): + raise ReleasePackageError(f"{label} contains path traversal or is not canonical: {value!r}") + return value + + +def _artifact_file(root: Path, relative_path: str, *, label: str) -> Path: + relative_path = _safe_relative_path(relative_path, label=label) + lexical = root.joinpath(*PurePosixPath(relative_path).parts) + _reject_symlink_components(lexical, label=label) + try: + resolved = lexical.resolve(strict=True) + resolved.relative_to(root) + except (OSError, ValueError) as exc: + raise ReleasePackageError(f"{label} escapes the release package or is missing.") from exc + if lexical.is_symlink() or not resolved.is_file(): + raise ReleasePackageError(f"{label} must be a real regular file, not a symbolic link.") + return resolved + + +def _artifact_pin(package: Mapping[str, Any], name: str) -> ArtifactPin: + artifacts = package.get("artifacts") + if not isinstance(artifacts, dict): + raise ReleasePackageError("release-package.json has no artifacts object.") + raw = artifacts.get(name) + if not isinstance(raw, dict): + raise ReleasePackageError(f"Required release package artifact is missing: {name}") + if set(raw) != {"path", "sha256"}: + raise ReleasePackageError(f"Artifact {name} fields are missing or unknown.") + path = _safe_relative_path(raw.get("path"), label=f"artifact {name} path") + digest = raw.get("sha256") + if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest): + raise ReleasePackageError(f"Artifact {name} has no valid lowercase SHA-256.") + return ArtifactPin(path=path, sha256=digest) + + +def _validate_identifier(value: object, pattern: re.Pattern[str], *, label: str) -> str: + if not isinstance(value, str) or not pattern.fullmatch(value): + raise ReleasePackageError(f"{label} is missing or unsafe.") + return value + + +def _validate_image_map( + value: object, + expected_keys: tuple[str, ...], + *, + label: str, +) -> dict[str, dict[str, str]]: + if not isinstance(value, dict) or set(value) != set(expected_keys): + raise ReleasePackageError(f"{label} must contain exactly: {', '.join(expected_keys)}") + result: dict[str, dict[str, str]] = {} + for key in expected_keys: + raw = value[key] + if not isinstance(raw, dict): + raise ReleasePackageError(f"{label}.{key} must be an object.") + reference = raw.get("reference") + digest = raw.get("digest") + if not isinstance(reference, str) or not IMAGE_REFERENCE_RE.fullmatch(reference): + raise ReleasePackageError(f"{label}.{key} is not an immutable safe image reference.") + if not isinstance(digest, str) or not IMAGE_DIGEST_RE.fullmatch(digest): + raise ReleasePackageError(f"{label}.{key} has no valid image digest.") + if reference.rsplit("@", 1)[1] != digest: + raise ReleasePackageError(f"{label}.{key} reference and digest do not match.") + result[key] = {"reference": reference, "digest": digest} + return result + + +def _validate_installable_manifest( + root: Path, + payload: Mapping[str, Any], + *, + source_commit: str, + application_images: Mapping[str, Mapping[str, str]], +) -> tuple[dict[str, Any], ...]: + if payload.get("schema_version") != INSTALLABLE_MANIFEST_SCHEMA: + raise ReleasePackageError("Unsupported installable release manifest schema.") + _verify_embedded_hash( + payload, + "manifest_payload_sha256", + label="installable release manifest", + ) + candidate = payload.get("candidate") + if not isinstance(candidate, dict) or candidate.get("commit") != source_commit: + raise ReleasePackageError("Installable manifest is not bound to the release source commit.") + images = payload.get("images") + expected_roles = [role for role, _key in IMPORT_ROLE_ENV_KEYS] + if ( + not isinstance(images, list) + or any(not isinstance(item, dict) for item in images) + or [item.get("role") for item in images] != expected_roles + ): + raise ReleasePackageError("Installable manifest must contain the four application roles in fixed order.") + images_by_role = {item["role"]: item for item in images} + + raw_plan = payload.get("docker_import_plan") + if not isinstance(raw_plan, dict) or raw_plan.get("schema_version") != DOCKER_IMPORT_PLAN_SCHEMA: + raise ReleasePackageError("Installable manifest has no supported docker_import_plan.") + if raw_plan.get("implicit_preloaded_images_allowed") is not False: + raise ReleasePackageError("Docker import plan must forbid implicit preloaded images.") + raw_imports = raw_plan.get("images") + if not isinstance(raw_imports, list) or len(raw_imports) != len(IMPORT_ROLE_ENV_KEYS): + raise ReleasePackageError("Docker import plan must contain exactly four application archives.") + + normalized: list[dict[str, Any]] = [] + seen_paths: set[str] = set() + seen_ids: set[str] = set() + for sequence, ((expected_role, expected_key), raw) in enumerate( + zip(IMPORT_ROLE_ENV_KEYS, raw_imports, strict=True), + start=1, + ): + if not isinstance(raw, dict): + raise ReleasePackageError("Docker import plan entries must be objects.") + if ( + raw.get("sequence") != sequence + or raw.get("role") != expected_role + or raw.get("environment_key") != expected_key + ): + raise ReleasePackageError("Docker import plan role, sequence, or environment key drifted.") + image = images_by_role[expected_role] + if image.get("environment_key") != expected_key: + raise ReleasePackageError("Installable image environment key drifted.") + + archive = raw.get("archive") + docker_archive = image.get("docker_archive") + if not isinstance(archive, dict) or archive != docker_archive: + raise ReleasePackageError("Docker import plan archive is not bound to its image record.") + if set(archive) != {"path", "sha256", "size_bytes"}: + raise ReleasePackageError("Docker archive record has unexpected or missing fields.") + relative = _safe_relative_path(archive.get("path"), label="Docker archive path") + digest = archive.get("sha256") + size = archive.get("size_bytes") + if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest): + raise ReleasePackageError("Docker archive record has an invalid SHA-256.") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + raise ReleasePackageError("Docker archive record has an invalid size.") + if relative in seen_paths: + raise ReleasePackageError("Docker import plan reuses an archive path.") + seen_paths.add(relative) + archive_path = _artifact_file(root, relative, label=f"{expected_role} Docker archive") + archive_stat = archive_path.stat() + if archive_stat.st_nlink != 1: + raise ReleasePackageError(f"{expected_role} Docker archive must not be hard linked.") + if archive_stat.st_size != size or file_sha256(archive_path) != digest: + raise ReleasePackageError(f"{expected_role} Docker archive size or SHA-256 mismatch.") + + config_digest = image.get("config_digest") + manifest_digest = image.get("manifest_digest") + registry_reference = image.get("registry_reference") + expected_tag = raw.get("expected_repo_tag") + if ( + not isinstance(expected_tag, str) + or not expected_tag + or len(expected_tag) > 512 + or any(character.isspace() or ord(character) < 32 for character in expected_tag) + ): + raise ReleasePackageError("Docker import plan has an unsafe expected repository tag.") + if not isinstance(config_digest, str) or not IMAGE_DIGEST_RE.fullmatch(config_digest): + raise ReleasePackageError("Installable image has an invalid config digest.") + if config_digest in seen_ids: + raise ReleasePackageError("Docker import plan reuses a config-based image ID.") + seen_ids.add(config_digest) + if not isinstance(manifest_digest, str) or not IMAGE_DIGEST_RE.fullmatch(manifest_digest): + raise ReleasePackageError("Installable image has an invalid OCI manifest digest.") + if not isinstance(registry_reference, str) or not IMAGE_REFERENCE_RE.fullmatch( + registry_reference + ): + raise ReleasePackageError("Installable image has an invalid online registry reference.") + if ( + raw.get("command_argv") + != [ + str(DOCKER_CLI), + "--host", + LOCAL_DOCKER_HOST, + "image", + "load", + "--input", + relative, + ] + or raw.get("expected_loaded_image_id") != config_digest + or raw.get("expected_oci_manifest_digest") != manifest_digest + or raw.get("offline_compose_override") != config_digest + or raw.get("registry_reference_for_online_release") != registry_reference + or raw.get("repo_digests_expected_after_load") != [] + or image.get("offline_image_id") != config_digest + or image.get("image_ref") != expected_tag + ): + raise ReleasePackageError("Docker import plan does not match the installable image identity.") + identity = application_images[expected_key] + if identity["reference"] != registry_reference or identity["digest"] != manifest_digest: + raise ReleasePackageError( + f"Docker import plan online identity does not match application_images.{expected_key}." + ) + normalized.append( + { + "sequence": sequence, + "role": expected_role, + "environment_key": expected_key, + "archive_path": relative, + "archive_sha256": digest, + "archive_size_bytes": size, + "expected_repo_tag": expected_tag, + "expected_loaded_image_id": config_digest, + "expected_oci_manifest_digest": manifest_digest, + "offline_compose_override": config_digest, + "registry_reference_for_online_release": registry_reference, + } + ) + return tuple(normalized) + + +def _validate_checksum_manifest( + root: Path, + payload: Mapping[str, Any], + *, + package_file_path: str, + checksum_file_path: str, +) -> dict[str, dict[str, Any]]: + if payload.get("schema_version") != CHECKSUM_SCHEMA: + raise ReleasePackageError("Unsupported checksum manifest schema.") + _verify_embedded_hash(payload, "manifest_payload_sha256", label="checksum manifest") + raw_items = payload.get("files") + if not isinstance(raw_items, list) or not raw_items: + raise ReleasePackageError("Checksum manifest must list package artifacts.") + records: dict[str, dict[str, Any]] = {} + casefolded: set[str] = set() + observed_order: list[str] = [] + for raw in raw_items: + if not isinstance(raw, dict): + raise ReleasePackageError("Checksum manifest file entries must be objects.") + relative = _safe_relative_path(raw.get("path"), label="checksum entry path") + digest = raw.get("sha256") + size = raw.get("size_bytes") + if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest): + raise ReleasePackageError(f"Checksum entry has invalid SHA-256: {relative}") + if not isinstance(size, int) or isinstance(size, bool) or size < 0: + raise ReleasePackageError(f"Checksum entry has invalid size: {relative}") + if relative in records or relative.casefold() in casefolded: + raise ReleasePackageError(f"Checksum manifest has a duplicate path: {relative}") + if relative in {package_file_path, checksum_file_path}: + raise ReleasePackageError("The package and checksum manifests must not checksum themselves.") + records[relative] = {"sha256": digest, "size_bytes": size} + casefolded.add(relative.casefold()) + observed_order.append(relative) + if observed_order != sorted(observed_order, key=lambda item: item.encode("utf-8")): + raise ReleasePackageError("Checksum manifest file entries must be bytewise path sorted.") + + for relative, record in records.items(): + target = _artifact_file(root, relative, label=f"checksummed artifact {relative}") + if target.stat().st_size != record["size_bytes"]: + raise ReleasePackageError(f"Artifact size mismatch: {relative}") + if file_sha256(target) != record["sha256"]: + raise ReleasePackageError(f"Artifact checksum mismatch: {relative}") + + actual_files: set[str] = set() + for path in root.rglob("*"): + if path.is_symlink(): + raise ReleasePackageError(f"Release package contains a symbolic link: {path}") + if path.is_file(): + actual_files.add(path.relative_to(root).as_posix()) + elif not path.is_dir(): + raise ReleasePackageError(f"Release package contains a non-regular entry: {path}") + expected_files = {package_file_path, checksum_file_path, *records} + if actual_files != expected_files: + missing = sorted(expected_files - actual_files) + unexpected = sorted(actual_files - expected_files) + raise ReleasePackageError( + "Release package file set differs from its checksum manifest: " + f"missing={missing}, unexpected={unexpected}" + ) + return records + + +def _validate_source_manifest(payload: Mapping[str, Any]) -> str: + if payload.get("schema_version") != SOURCE_MANIFEST_SCHEMA: + raise ReleasePackageError("Unsupported release source manifest schema.") + _verify_embedded_hash(payload, "manifest_payload_sha256", label="release source manifest") + candidate = payload.get("candidate") + if not isinstance(candidate, dict): + raise ReleasePackageError("Release source manifest has no candidate identity.") + commit = _validate_identifier(candidate.get("commit"), COMMIT_RE, label="source commit") + if candidate.get("worktree_was_clean") is not True: + raise ReleasePackageError("Release source manifest is not from a clean candidate.") + files = payload.get("files") + if not isinstance(files, dict) or not isinstance(files.get("items"), list): + raise ReleasePackageError("Release source manifest has no file inventory.") + if files.get("count") != len(files["items"]): + raise ReleasePackageError("Release source manifest file count is inconsistent.") + return commit + + +def _validate_compose_inputs(source_manifest: Mapping[str, Any], repo_root: Path) -> None: + root = _validated_directory(repo_root, label="repository root") + raw_items = source_manifest["files"]["items"] + by_path: dict[str, Mapping[str, Any]] = {} + for item in raw_items: + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + raise ReleasePackageError("Release source manifest contains an invalid file record.") + path = _safe_relative_path(item["path"], label="release source file path") + if path in by_path: + raise ReleasePackageError(f"Duplicate release source file path: {path}") + by_path[path] = item + for relative in ( + BASE_COMPOSE_RELATIVE, + PRODUCTION_COMPOSE_RELATIVE, + OFFLINE_COMPOSE_RELATIVE, + ): + item = by_path.get(relative) + if not isinstance(item, Mapping): + raise ReleasePackageError(f"Release source manifest does not bind Compose input: {relative}") + expected = item.get("sha256") + if not isinstance(expected, str) or not SHA256_RE.fullmatch(expected): + raise ReleasePackageError(f"Release source manifest has an invalid Compose hash: {relative}") + target = _artifact_file(root, relative, label=f"repository Compose input {relative}") + if file_sha256(target) != expected: + raise ReleasePackageError(f"Repository Compose input differs from release manifest: {relative}") + + +def _validate_backup_proof( + payload: Mapping[str, Any], + *, + release_id: str, + rollback_release_id: str, +) -> None: + if payload.get("schema_version") != BACKUP_PROOF_SCHEMA: + raise ReleasePackageError("Unsupported backup proof schema.") + _verify_embedded_hash(payload, "proof_payload_sha256", label="backup proof") + if payload.get("release_id") != release_id or payload.get("rollback_target_release_id") != rollback_release_id: + raise ReleasePackageError("Backup proof is not bound to this release transition.") + if payload.get("status") != "unverified" or payload.get("restore_tested") is not False: + raise ReleasePackageError( + "Self-hashed backup material must remain explicitly unverified." + ) + backup_id = payload.get("backup_id") + if backup_id is not None: + _validate_identifier(backup_id, APPROVAL_RE, label="backup proof backup_id") + backup_manifest_sha = payload.get("backup_manifest_sha256") + if backup_manifest_sha is not None and ( + not isinstance(backup_manifest_sha, str) + or not SHA256_RE.fullmatch(backup_manifest_sha) + ): + raise ReleasePackageError("Backup proof has an invalid backup manifest SHA-256.") + + +def _validate_compatibility_proof( + payload: Mapping[str, Any], + *, + release_id: str, + rollback_release_id: str, + rollback_images: Mapping[str, Mapping[str, str]], +) -> None: + if payload.get("schema_version") != COMPATIBILITY_PROOF_SCHEMA: + raise ReleasePackageError("Unsupported rollback compatibility proof schema.") + _verify_embedded_hash(payload, "proof_payload_sha256", label="rollback compatibility proof") + if payload.get("from_release_id") != release_id or payload.get("to_release_id") != rollback_release_id: + raise ReleasePackageError("Compatibility proof is not bound to this release transition.") + if payload.get("status") != "unverified": + raise ReleasePackageError( + "Self-hashed compatibility material must remain explicitly unverified." + ) + if payload.get("approval_id") is not None: + raise ReleasePackageError( + "Untrusted compatibility material must not claim an approval identifier." + ) + if payload.get("database_downgrade_required") is not False: + raise ReleasePackageError("Database downgrade is forbidden.") + if payload.get("current_schema_is_backward_compatible") not in {None, False}: + raise ReleasePackageError( + "Untrusted compatibility material must not claim schema compatibility." + ) + approved = payload.get("approved_application_digests") + if approved is not None and approved != {} and approved != []: + raise ReleasePackageError( + "Untrusted compatibility material must not claim approved image digests." + ) + + +def validate_release_package( + package_dir: Path, + *, + expected_package_sha256: str, + repo_root: Path = REPO_ROOT, +) -> ValidatedPackage: + if not SHA256_RE.fullmatch(expected_package_sha256): + raise ReleasePackageError("--expected-package-sha256 must be a lowercase SHA-256.") + root = _validated_directory(package_dir, label="release package directory") + package_path = _artifact_file(root, PACKAGE_FILE, label="release-package.json") + package_sha = file_sha256(package_path) + if package_sha != expected_package_sha256: + raise ReleasePackageError("release-package.json does not match the externally approved SHA-256.") + package = _read_json(package_path, label="release-package.json") + if package.get("schema_version") != PACKAGE_SCHEMA: + raise ReleasePackageError("Unsupported release package schema.") + _verify_embedded_hash(package, "package_payload_sha256", label="release-package.json") + expected_package_fields = { + "schema_version", + "release_id", + "compose_project_name", + "deployment_scope", + "deployed_service_set", + "separately_deployed_artifacts", + "trusted_build_receipt_sha256", + "artifacts", + "package_payload_sha256", + } + if set(package) != expected_package_fields: + raise ReleasePackageError("release-package.json fields are missing or unknown.") + trusted_build_receipt_sha256 = package.get("trusted_build_receipt_sha256") + if not isinstance(trusted_build_receipt_sha256, str) or not SHA256_RE.fullmatch( + trusted_build_receipt_sha256 + ): + raise ReleasePackageError( + "release-package.json has no trusted build receipt raw SHA-256." + ) + release_id = _validate_identifier(package.get("release_id"), RELEASE_ID_RE, label="release_id") + _validate_identifier(package.get("compose_project_name"), PROJECT_RE, label="compose_project_name") + if package.get("deployment_scope") != DEPLOYMENT_SCOPE: + raise ReleasePackageError("release-package.json must declare deployment_scope=backend_only.") + if package.get("deployed_service_set") != list(INSTALL_SERVICES): + raise ReleasePackageError( + "release-package.json deployed_service_set must equal the fixed backend install allowlist." + ) + if package.get("separately_deployed_artifacts") != list(SEPARATELY_DEPLOYED_IMAGE_KEYS): + raise ReleasePackageError( + "release-package.json must identify Contract Web as a separately deployed artifact." + ) + artifacts = package.get("artifacts") + artifact_names = set(artifacts) if isinstance(artifacts, dict) else set() + if ( + not isinstance(artifacts, dict) + or not set(REQUIRED_ARTIFACTS).issubset(artifact_names) + or not artifact_names.issubset( + set(REQUIRED_ARTIFACTS + OPTIONAL_EVIDENCE_ARTIFACTS) + ) + ): + raise ReleasePackageError( + "release-package.json must contain exactly the required release manifest, " + "checksum manifest, image identity, and installable manifest artifacts, " + "plus only optional unverified backup/compatibility material." + ) + pins = {name: _artifact_pin(package, name) for name in artifacts} + if len({pin.path.casefold() for pin in pins.values()}) != len(pins): + raise ReleasePackageError("Release package artifact paths must be unique.") + + checksum_path = _artifact_file(root, pins["checksum_manifest"].path, label="checksum manifest") + checksum_sha = file_sha256(checksum_path) + if checksum_sha != pins["checksum_manifest"].sha256: + raise ReleasePackageError("Checksum manifest SHA-256 does not match release-package.json.") + checksum_payload = _read_json(checksum_path, label="checksum manifest") + if checksum_payload.get("release_id") != release_id: + raise ReleasePackageError("Checksum manifest release_id mismatch.") + checksum_records = _validate_checksum_manifest( + root, + checksum_payload, + package_file_path=PACKAGE_FILE, + checksum_file_path=pins["checksum_manifest"].path, + ) + for name, pin in pins.items(): + if name == "checksum_manifest": + continue + record = checksum_records.get(pin.path) + if record is None or record["sha256"] != pin.sha256: + raise ReleasePackageError(f"Checksum manifest does not bind artifact {name}.") + + release_path = _artifact_file(root, pins["release_manifest"].path, label="release source manifest") + release_sha = file_sha256(release_path) + if release_sha != pins["release_manifest"].sha256: + raise ReleasePackageError("Release source manifest SHA-256 mismatch.") + release_manifest = _read_json(release_path, label="release source manifest") + source_commit = _validate_source_manifest(release_manifest) + _validate_compose_inputs(release_manifest, repo_root) + + identity_path = _artifact_file(root, pins["image_identity"].path, label="image identity") + identity_sha = file_sha256(identity_path) + if identity_sha != pins["image_identity"].sha256: + raise ReleasePackageError("Image identity SHA-256 mismatch.") + image_identity = _read_json(identity_path, label="image identity") + if image_identity.get("schema_version") != IMAGE_IDENTITY_SCHEMA: + raise ReleasePackageError("Unsupported image identity schema.") + _verify_embedded_hash(image_identity, "identity_payload_sha256", label="image identity") + if ( + image_identity.get("release_id") != release_id + or image_identity.get("source_commit") != source_commit + or image_identity.get("source_manifest_sha256") != release_sha + ): + raise ReleasePackageError("Image identity is not bound to the release manifest and source commit.") + application_images = _validate_image_map( + image_identity.get("application_images"), + APPLICATION_IMAGE_KEYS, + label="application_images", + ) + infrastructure_images = _validate_image_map( + image_identity.get("infrastructure_images"), + INFRASTRUCTURE_IMAGE_KEYS, + label="infrastructure_images", + ) + rollback = image_identity.get("rollback") + if not isinstance(rollback, dict) or set(rollback) != { + "target_release_id", + "evidence_status", + "application_images", + }: + raise ReleasePackageError("Image identity has no complete rollback candidate.") + if rollback.get("evidence_status") != "external_approval_missing": + raise ReleasePackageError( + "Rollback image identity must remain unapproved until external evidence exists." + ) + rollback_release_id = _validate_identifier( + rollback.get("target_release_id"), RELEASE_ID_RE, label="rollback target release_id" + ) + rollback_images = _validate_image_map( + rollback.get("application_images"), + APPLICATION_IMAGE_KEYS, + label="rollback.application_images", + ) + if rollback_images == application_images: + raise ReleasePackageError("Rollback image identity must differ from the install image identity.") + + installable_path = _artifact_file( + root, + pins["installable_manifest"].path, + label="installable release manifest", + ) + installable_sha = file_sha256(installable_path) + if installable_sha != pins["installable_manifest"].sha256: + raise ReleasePackageError("Installable release manifest SHA-256 mismatch.") + installable_validator = _load_installable_validator_module() + try: + installable_manifest = installable_validator.validate_installable_release_manifest( + artifact_root=installable_path.parent, + manifest_path=installable_path, + expected_receipt_sha256=trusted_build_receipt_sha256, + repo=_validated_directory(repo_root, label="repository root"), + ) + except installable_validator.ReleaseValidationError as exc: + raise ReleasePackageError(f"Strict installable manifest validation failed: {exc}") from exc + bundle_prefix = installable_path.parent.relative_to(root) + for record in installable_manifest.get("files", []): + if not isinstance(record, dict): + raise ReleasePackageError("Installable manifest file inventory is invalid.") + relative = _safe_relative_path( + record.get("path"), + label="installable manifest file path", + ) + package_relative = (bundle_prefix / PurePosixPath(relative)).as_posix() + outer = checksum_records.get(package_relative) + if outer != { + "sha256": record.get("sha256"), + "size_bytes": record.get("size_bytes"), + }: + raise ReleasePackageError( + f"Outer checksum manifest does not bind installable file: {package_relative}" + ) + receipt_record = installable_manifest.get("build_receipt") + if ( + not isinstance(receipt_record, dict) + or receipt_record.get("sha256") != trusted_build_receipt_sha256 + ): + raise ReleasePackageError( + "Installable manifest is not bound to the trusted build receipt raw SHA-256." + ) + build_receipt_path = _artifact_file( + installable_path.parent, + str(receipt_record["path"]), + label="trusted build receipt", + ) + build_receipt = _read_json(build_receipt_path, label="trusted build receipt") + receipt_candidate = build_receipt.get("candidate") + source_record = ( + receipt_candidate.get("source_manifest") + if isinstance(receipt_candidate, dict) + else None + ) + if not isinstance(source_record, dict): + raise ReleasePackageError("Trusted build receipt has no source manifest record.") + receipt_source_path = _artifact_file( + installable_path.parent, + str(source_record.get("path")), + label="trusted build receipt source manifest", + ) + if receipt_source_path != release_path or source_record.get("sha256") != release_sha: + raise ReleasePackageError( + "Release package source manifest is not the build receipt source manifest." + ) + docker_import_plan = _validate_installable_manifest( + installable_path.parent, + installable_manifest, + source_commit=source_commit, + application_images=application_images, + ) + + backup_proof: dict[str, Any] | None = None + backup_sha: str | None = None + if "backup_proof" in pins: + backup_path = _artifact_file(root, pins["backup_proof"].path, label="backup proof") + backup_sha = file_sha256(backup_path) + if backup_sha != pins["backup_proof"].sha256: + raise ReleasePackageError("Backup proof SHA-256 mismatch.") + backup_proof = _read_json(backup_path, label="backup proof") + _validate_backup_proof( + backup_proof, + release_id=release_id, + rollback_release_id=rollback_release_id, + ) + + compatibility_proof: dict[str, Any] | None = None + compatibility_sha: str | None = None + if "compatibility_proof" in pins: + compatibility_path = _artifact_file( + root, pins["compatibility_proof"].path, label="rollback compatibility proof" + ) + compatibility_sha = file_sha256(compatibility_path) + if compatibility_sha != pins["compatibility_proof"].sha256: + raise ReleasePackageError("Rollback compatibility proof SHA-256 mismatch.") + compatibility_proof = _read_json( + compatibility_path, + label="rollback compatibility proof", + ) + _validate_compatibility_proof( + compatibility_proof, + release_id=release_id, + rollback_release_id=rollback_release_id, + rollback_images=rollback_images, + ) + + return ValidatedPackage( + root=root, + package=package, + package_sha256=package_sha, + checksum_manifest=checksum_payload, + checksum_manifest_sha256=checksum_sha, + release_manifest=release_manifest, + release_manifest_sha256=release_sha, + image_identity=image_identity, + image_identity_sha256=identity_sha, + installable_manifest=installable_manifest, + installable_manifest_sha256=installable_sha, + installable_root=installable_path.parent, + trusted_build_receipt_sha256=trusted_build_receipt_sha256, + docker_import_plan=docker_import_plan, + backup_proof=backup_proof, + backup_proof_sha256=backup_sha, + compatibility_proof=compatibility_proof, + compatibility_proof_sha256=compatibility_sha, + application_images=application_images, + infrastructure_images=infrastructure_images, + rollback_images=rollback_images, + rollback_release_id=rollback_release_id, + ) + + +def parse_env_file(path: Path) -> dict[str, str]: + compose_gate = _load_compose_gate_module() + try: + _target, values = compose_gate.parse_strict_env(path) + except compose_gate.ComposeGateError as exc: + raise ReleasePackageError(str(exc)) from exc + return values + + +def _fixed_runtime_environment(overrides: Mapping[str, str] | None = None) -> dict[str, str]: + environment = { + "HOME": FIXED_DOCKER_HOME, + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + } + if overrides: + reserved = { + "PATH", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "COMPOSE_FILE", + "COMPOSE_PATH_SEPARATOR", + "COMPOSE_PROFILES", + "COMPOSE_PROJECT_NAME", + } + conflict = sorted(reserved.intersection(overrides)) + if conflict: + raise ReleasePackageError( + f"Runtime overrides contain forbidden ambient-control keys: {', '.join(conflict)}" + ) + environment.update(overrides) + return environment + + +def _run_production_preflight(env_values: dict[str, str]) -> dict[str, Any]: + preflight = _load_preflight_module() + try: + environment_checks = preflight.validate_environment(dict(env_values)) + file_checks = preflight.validate_files(dict(env_values)) + except Exception as exc: + raise ReleasePackageError("Production preflight raised while validating protected inputs.") from exc + + environment_records = [ + {"key": check.key, "status": check.status} for check in environment_checks + ] + file_records = [{"key": check.key, "status": check.status} for check in file_checks] + failed = [ + f"environment:{record['key']}" + for record in environment_records + if record["status"] == "fail" + ] + [ + f"files:{record['key']}" + for record in file_records + if record["status"] == "fail" + ] + if failed: + raise ReleasePackageError( + "Production preflight failed before any state write: " + ", ".join(failed) + ) + summary: dict[str, Any] = { + "schema_version": PREFLIGHT_BINDING_SCHEMA, + "status": "passed", + "environment_checks": environment_records, + "file_checks": file_records, + } + summary["summary_sha256"] = canonical_sha256(summary) + return summary + + +def _parse_json_text(value: object, *, label: str, max_bytes: int = MAX_JSON_BYTES) -> dict[str, Any]: + raw = _output_bytes(value) + if len(raw) > max_bytes: + raise ReleasePackageError(f"{label} exceeds the maximum allowed size.") + try: + payload = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_reject_duplicate_pairs, + parse_constant=_reject_json_constant, + ) + except ReleasePackageError: + raise + except (UnicodeError, ValueError, TypeError) as exc: + raise ReleasePackageError(f"{label} is not strict UTF-8 JSON.") from exc + if not isinstance(payload, dict): + raise ReleasePackageError(f"{label} must be a JSON object.") + return payload + + +def _docker_daemon_identity( + *, + runner: Callable[..., Any], + repo_root: Path, +) -> dict[str, str]: + command = [ + str(DOCKER_CLI), + "--host", + LOCAL_DOCKER_HOST, + "info", + "--format", + "{{json .}}", + ] + try: + completed = runner( + command, + check=False, + capture_output=True, + text=True, + shell=False, + cwd=str(repo_root), + env=_fixed_runtime_environment(), + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ReleasePackageError("Cannot query the fixed local Docker daemon identity.") from exc + if int(completed.returncode) != 0: + raise ReleasePackageError("Cannot query the fixed local Docker daemon identity.") + payload = _parse_json_text(completed.stdout, label="Docker daemon identity") + fields = { + "daemon_id": payload.get("ID"), + "daemon_name": payload.get("Name"), + "docker_root_dir": payload.get("DockerRootDir"), + "server_version": payload.get("ServerVersion"), + } + if any(not isinstance(value, str) or not value.strip() for value in fields.values()): + raise ReleasePackageError("Docker daemon identity is incomplete.") + identity: dict[str, str] = { + "docker_cli": str(DOCKER_CLI), + "docker_host": LOCAL_DOCKER_HOST, + **{key: str(value) for key, value in fields.items()}, + } + identity["identity_sha256"] = canonical_sha256(identity) + return identity + + +def _collect_runtime_evidence( + *, + env_file: Path, + repo_root: Path, + inspection_runner: Callable[..., Any], +) -> RuntimeEvidence: + env_target = _validated_file(env_file, label="production env file", max_bytes=MAX_ENV_BYTES) + env_values = parse_env_file(env_target) + preflight_summary = _run_production_preflight(env_values) + + compose_gate = _load_compose_gate_module() + try: + compose_report = compose_gate.verify_production_compose( + env_file=env_target, + repo_root=repo_root, + runner=inspection_runner, + ) + except compose_gate.ComposeGateError as exc: + raise ReleasePackageError(f"Production Compose merge gate failed: {exc}") from exc + compose_summary = dict(compose_report) + compose_summary["summary_sha256"] = canonical_sha256(compose_summary) + daemon_identity = _docker_daemon_identity(runner=inspection_runner, repo_root=repo_root) + return RuntimeEvidence( + env_file=env_target, + env_values=env_values, + env_sha256=file_sha256(env_target), + preflight_summary=preflight_summary, + compose_gate_summary=compose_summary, + daemon_identity=daemon_identity, + ) + + +def _run_offline_execution_gate( + *, + env_file: Path, + repo_root: Path, + compose_files: tuple[Path, Path, Path], + project_name: str, + offline_application_overrides: Mapping[str, str], + inspection_runner: Callable[..., Any], +) -> dict[str, Any]: + compose_gate = _load_compose_gate_module() + env_values = parse_env_file(env_file) + env_values.update(offline_application_overrides) + child_env = _fixed_runtime_environment(offline_application_overrides) + cases: list[dict[str, Any]] = [] + try: + for profile_case in compose_gate.PROFILE_CASES: + command = compose_gate._command( + project_name=project_name, + env_file=env_file, + compose_files=compose_files, + profile_case=profile_case, + ) + payload = compose_gate._run_config( + command, + repo_root=repo_root, + runner=inspection_runner, + child_env=child_env, + label=f"offline-execution/{profile_case.name}", + ) + cases.append( + compose_gate._validate_model( + payload, + env_values=env_values, + mode="offline", + profile_case=profile_case, + ) + ) + except compose_gate.ComposeGateError as exc: + raise ReleasePackageError(f"Offline execution Compose gate failed: {exc}") from exc + summary: dict[str, Any] = { + "schema_version": "yuqei.offline-compose-execution-gate.v1", + "status": "passed", + "docker_host": LOCAL_DOCKER_HOST, + "project_name": project_name, + "offline_application_overrides": dict(offline_application_overrides), + "cases": cases, + } + summary["summary_sha256"] = canonical_sha256(summary) + return summary + + +def _assert_safe_compose_command(command: list[str], *, operation: str) -> None: + if command[:4] != [str(DOCKER_CLI), "--host", LOCAL_DOCKER_HOST, "compose"]: + raise ReleasePackageError("Only the fixed local Docker Compose target is allowed.") + if any("\x00" in item or "\r" in item or "\n" in item for item in command): + raise ReleasePackageError("Deployment argv contains a forbidden control character.") + if command.count("up") != 1 or command.count("--pull") != 1 or "--no-build" not in command: + raise ReleasePackageError("Offline execution requires compose up --pull never --no-build.") + pull_index = command.index("--pull") + if pull_index + 1 >= len(command) or command[pull_index + 1] != "never": + raise ReleasePackageError("Offline execution requires the exact --pull never policy.") + lowered = [item.casefold() for item in command] + for index, token in enumerate(lowered): + if token in FORBIDDEN_COMMAND_TOKENS: + pull_pair = token == "pull" and index > 0 and lowered[index - 1] == "--pull" + if not pull_pair: + raise ReleasePackageError(f"Forbidden deployment command token: {command[index]}") + if "edge" in lowered or any("dns" in item for item in lowered): + raise ReleasePackageError("Automatic edge or DNS operations are forbidden.") + if operation in {"rollback", "forward"} and "--no-deps" not in command: + raise ReleasePackageError("Rollback and forward must switch application services with --no-deps.") + expected_services = RUNTIME_SERVICES if operation in {"rollback", "forward"} else INSTALL_SERVICES + if command[-len(expected_services) :] != list(expected_services): + raise ReleasePackageError("Deployment command service set is not the fixed operation allowlist.") + + +def _compose_command( + *, + operation: str, + project_name: str, + env_file: Path, + repo_root: Path, + compose_files: tuple[Path, Path, Path] | None = None, +) -> list[str]: + if compose_files is None: + root = _validated_directory(repo_root, label="repository root") + base = _artifact_file(root, BASE_COMPOSE_RELATIVE, label="base Compose file") + production = _artifact_file( + root, + PRODUCTION_COMPOSE_RELATIVE, + label="production Compose file", + ) + offline = _artifact_file( + root, + OFFLINE_COMPOSE_RELATIVE, + label="offline Compose override", + ) + else: + base, production, offline = compose_files + for label, path in ( + ("base Compose snapshot", base), + ("production Compose snapshot", production), + ("offline Compose snapshot", offline), + ): + _validated_file(path, label=label, max_bytes=MAX_JSON_BYTES) + command = [ + str(DOCKER_CLI), + "--host", + LOCAL_DOCKER_HOST, + "compose", + "--project-name", + project_name, + "--env-file", + str(env_file), + "-f", + str(base), + "-f", + str(production), + "-f", + str(offline), + "up", + "-d", + "--pull", + "never", + "--no-build", + ] + if operation in {"rollback", "forward"}: + command.append("--no-deps") + command.extend(RUNTIME_SERVICES) + else: + command.extend(INSTALL_SERVICES) + _assert_safe_compose_command(command, operation=operation) + return command + + +def build_plan( + package: ValidatedPackage, + *, + operation: str, + env_file: Path, + repo_root: Path = REPO_ROOT, + inspection_runner: Callable[..., Any] = subprocess.run, +) -> dict[str, Any]: + if operation not in CONFIRMATIONS: + raise ReleasePackageError("Operation must be install, rollback, or forward.") + if not env_file.is_absolute(): + raise ReleasePackageError("--env-file must be an absolute path.") + evidence = _collect_runtime_evidence( + env_file=env_file, + repo_root=repo_root, + inspection_runner=inspection_runner, + ) + env_target = evidence.env_file + env_values = evidence.env_values + project_name = package.package["compose_project_name"] + if env_values.get("COMPOSE_PROJECT_NAME") != project_name: + raise ReleasePackageError("Production env COMPOSE_PROJECT_NAME does not match the release package.") + + if operation == "rollback": + target_release_id = package.rollback_release_id + application_images = package.rollback_images + else: + target_release_id = package.package["release_id"] + application_images = package.application_images + environment_overrides = { + key: application_images[key]["reference"] for key in BACKEND_APPLICATION_IMAGE_KEYS + } + environment_overrides["AI_PLATFORM_RELEASE_IMAGE_DIGEST"] = application_images[ + "AI_PLATFORM_API_IMAGE" + ]["digest"] + if operation == "install": + environment_overrides.update( + { + key: package.infrastructure_images[key]["reference"] + for key in BACKEND_INSTALL_INFRASTRUCTURE_IMAGE_KEYS + } + ) + import_plan = [dict(item) for item in package.docker_import_plan] + offline_application_overrides = ( + { + item["environment_key"]: item["offline_compose_override"] + for item in import_plan + } + if operation in {"install", "forward"} + else {} + ) + apply_blockers = _real_apply_blockers(operation) + source_root = _validated_directory(repo_root, label="repository root") + source_compose_files = ( + _artifact_file(source_root, BASE_COMPOSE_RELATIVE, label="base Compose file"), + _artifact_file( + source_root, + PRODUCTION_COMPOSE_RELATIVE, + label="production Compose file", + ), + _artifact_file( + source_root, + OFFLINE_COMPOSE_RELATIVE, + label="offline Compose override", + ), + ) + offline_execution_gate = _run_offline_execution_gate( + env_file=env_target, + repo_root=source_root, + compose_files=source_compose_files, + project_name=project_name, + offline_application_overrides=offline_application_overrides, + inspection_runner=inspection_runner, + ) + command = _compose_command( + operation=operation, + project_name=project_name, + env_file=env_target, + repo_root=repo_root, + ) + unsigned: dict[str, Any] = { + "schema_version": PLAN_SCHEMA, + "mode": "plan", + "operation": operation, + "deployment_scope": DEPLOYMENT_SCOPE, + "deployed_service_set": list( + RUNTIME_SERVICES if operation in {"rollback", "forward"} else INSTALL_SERVICES + ), + "separately_deployed_artifacts": [ + { + "image_key": key, + "reference": application_images[key]["reference"], + "digest": application_images[key]["digest"], + "deployment_scope": "separate_web_orchestrator", + "applied_by_this_operation": False, + } + for key in SEPARATELY_DEPLOYED_IMAGE_KEYS + ], + "identity_bound_image_keys": list(APPLICATION_IMAGE_KEYS + INFRASTRUCTURE_IMAGE_KEYS), + "release_completion_claimed": False, + "release_id": package.package["release_id"], + "target_release_id": target_release_id, + "package_manifest_sha256": package.package_sha256, + "release_manifest_sha256": package.release_manifest_sha256, + "checksum_manifest_sha256": package.checksum_manifest_sha256, + "image_identity_sha256": package.image_identity_sha256, + "installable_manifest_sha256": package.installable_manifest_sha256, + "trusted_build_receipt_sha256": package.trusted_build_receipt_sha256, + "backup_proof_sha256": package.backup_proof_sha256, + "compatibility_proof_sha256": package.compatibility_proof_sha256, + "env_file": str(env_target), + "env_file_sha256": evidence.env_sha256, + "compose_project_name": project_name, + "docker_daemon_identity": evidence.daemon_identity, + "production_preflight": evidence.preflight_summary, + "compose_merge_gate": evidence.compose_gate_summary, + "offline_execution_gate": offline_execution_gate, + "docker_import_plan": import_plan, + "offline_application_overrides": offline_application_overrides, + "apply_eligible": False, + "backup_compatibility_evidence_status": ( + "optional_self_hashed_material_untrusted_external_signatures_missing_blocked" + ), + "backup_evidence_status": ( + "missing" if package.backup_proof is None else "unverified_self_hashed" + ), + "compatibility_evidence_status": ( + "missing" + if package.compatibility_proof is None + else "unverified_self_hashed" + ), + "real_apply_feature_gate": { + "status": "blocked", + "blockers": apply_blockers, + }, + "offline": True, + "pull_policy": "never", + "build_allowed": False, + "database_downgrade_allowed": False, + "edge_or_dns_change_allowed": False, + "environment_overrides": environment_overrides, + "command_argv": command, + } + unsigned["plan_sha256"] = canonical_sha256(unsigned) + return unsigned + + +def verification_payload(package: ValidatedPackage) -> dict[str, Any]: + payload: dict[str, Any] = { + "schema_version": PLAN_SCHEMA, + "mode": "verify", + "status": "structurally_verified_not_apply_eligible", + "package_structure_valid": True, + "apply_eligible": False, + "real_apply_feature_gate": { + "status": "blocked", + "blockers": list(REAL_APPLY_BLOCKERS), + }, + "deployment_scope": DEPLOYMENT_SCOPE, + "deployed_service_set": [], + "managed_backend_service_set": list(INSTALL_SERVICES), + "separately_deployed_artifacts": [ + { + "image_key": key, + "reference": package.application_images[key]["reference"], + "digest": package.application_images[key]["digest"], + "deployment_scope": "separate_web_orchestrator", + "applied_by_this_operation": False, + } + for key in SEPARATELY_DEPLOYED_IMAGE_KEYS + ], + "identity_bound_image_keys": list(APPLICATION_IMAGE_KEYS + INFRASTRUCTURE_IMAGE_KEYS), + "release_completion_claimed": False, + "release_id": package.package["release_id"], + "rollback_target_release_id": package.rollback_release_id, + "package_manifest_sha256": package.package_sha256, + "release_manifest_sha256": package.release_manifest_sha256, + "checksum_manifest_sha256": package.checksum_manifest_sha256, + "image_identity_sha256": package.image_identity_sha256, + "installable_manifest_sha256": package.installable_manifest_sha256, + "trusted_build_receipt_sha256": package.trusted_build_receipt_sha256, + "docker_import_plan_status": "validated_for_four_application_images", + "infrastructure_import_plan_status": "missing_blocked", + "backup_compatibility_evidence_status": ( + "optional_self_hashed_material_untrusted_external_signatures_missing_blocked" + ), + "backup_evidence_status": ( + "missing" if package.backup_proof is None else "unverified_self_hashed" + ), + "compatibility_evidence_status": ( + "missing" + if package.compatibility_proof is None + else "unverified_self_hashed" + ), + "backup_proof_sha256": package.backup_proof_sha256, + "compatibility_proof_sha256": package.compatibility_proof_sha256, + "offline": True, + "writes_performed": False, + } + payload["plan_sha256"] = canonical_sha256(payload) + return payload + + +def _validate_state_target(state_dir: Path, *, package_root: Path) -> Path: + lexical = _absolute_lexical(state_dir) + if not state_dir.is_absolute(): + raise ReleasePackageError("--state-dir must be an absolute path.") + if lexical == Path(lexical.anchor): + raise ReleasePackageError("--state-dir must not be a filesystem root.") + _reject_symlink_components(lexical, label="state directory") + existing = lexical + while not existing.exists() and existing != existing.parent: + existing = existing.parent + try: + existing_resolved = existing.resolve(strict=True) + except OSError as exc: + raise ReleasePackageError("State directory parent is unavailable.") from exc + if not existing_resolved.is_dir(): + raise ReleasePackageError("State directory parent is not a directory.") + try: + lexical.resolve(strict=False).relative_to(package_root) + except ValueError: + pass + else: + raise ReleasePackageError("State directory must be outside the immutable release package.") + if lexical.exists() and not lexical.is_dir(): + raise ReleasePackageError("State directory path is not a directory.") + return lexical + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + descriptor = os.open(path, flags) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _prepare_secure_state_root(state_dir: Path, *, require_root_owner: bool) -> None: + existed = state_dir.exists() + state_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + _reject_symlink_components(state_dir, label="state directory") + if not state_dir.is_dir() or state_dir.is_symlink(): + raise ReleasePackageError("State root must be a real directory.") + if os.name != "nt": + if not existed: + os.chmod(state_dir, 0o700) + state_stat = state_dir.stat() + expected_uid = 0 if require_root_owner else os.geteuid() + if state_stat.st_uid != expected_uid: + raise ReleasePackageError("State root has the wrong owner.") + if stat.S_IMODE(state_stat.st_mode) != 0o700: + raise ReleasePackageError("State root must have mode 0700.") + _fsync_directory(state_dir.parent) + + +def _secure_snapshot_copy( + source: Path, + destination: Path, + *, + expected_sha256: str, + expected_size: int | None = None, + label: str, +) -> dict[str, Any]: + source = _validated_file(source, label=label) + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + source_descriptor = os.open(source, flags) + except OSError as exc: + raise ReleasePackageError(f"Cannot open {label} for an immutable snapshot.") from exc + destination_descriptor = -1 + try: + before = os.fstat(source_descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: + raise ReleasePackageError(f"{label} must be a single-link regular file.") + if expected_size is not None and before.st_size != expected_size: + raise ReleasePackageError(f"{label} size changed before snapshot.") + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + _reject_symlink_components(destination.parent, label="snapshot directory") + destination_descriptor = os.open( + destination, + os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0), + 0o600, + ) + digest = hashlib.sha256() + copied = 0 + while True: + chunk = os.read(source_descriptor, 1024 * 1024) + if not chunk: + break + digest.update(chunk) + copied += len(chunk) + view = memoryview(chunk) + while view: + written = os.write(destination_descriptor, view) + view = view[written:] + after = os.fstat(source_descriptor) + if ( + before.st_dev, + before.st_ino, + before.st_size, + getattr(before, "st_mtime_ns", None), + ) != ( + after.st_dev, + after.st_ino, + after.st_size, + getattr(after, "st_mtime_ns", None), + ): + raise ReleasePackageError(f"{label} changed while being snapshotted.") + observed_sha256 = digest.hexdigest() + if observed_sha256 != expected_sha256: + raise ReleasePackageError(f"{label} SHA-256 changed before snapshot.") + if expected_size is not None and copied != expected_size: + raise ReleasePackageError(f"{label} size changed while being snapshotted.") + os.fsync(destination_descriptor) + except OSError as exc: + raise ReleasePackageError(f"Cannot create immutable snapshot for {label}.") from exc + finally: + os.close(source_descriptor) + if destination_descriptor >= 0: + os.close(destination_descriptor) + if os.name != "nt": + os.chmod(destination, 0o600) + destination_stat = destination.stat() + if destination_stat.st_nlink != 1: + raise ReleasePackageError(f"Snapshot for {label} is unexpectedly hard linked.") + return { + "path": destination.as_posix(), + "sha256": expected_sha256, + "size_bytes": destination_stat.st_size, + } + + +def _snapshot_inputs( + *, + state_dir: Path, + idempotency_key: str, + plan: Mapping[str, Any], + package: ValidatedPackage, + repo_root: Path, +) -> SnapshotInputs: + key_digest = hashlib.sha256(idempotency_key.encode("utf-8")).hexdigest() + snapshots_root = state_dir / "snapshots" + snapshots_root.mkdir(mode=0o700, exist_ok=True) + _reject_symlink_components(snapshots_root, label="snapshot root") + snapshot_root = snapshots_root / key_digest + try: + snapshot_root.mkdir(mode=0o700) + except FileExistsError as exc: + raise ReleasePackageError( + "A prior snapshot exists for this idempotency key; reconciliation is required." + ) from exc + + records: list[dict[str, Any]] = [] + env_destination = snapshot_root / "production.env" + records.append( + _secure_snapshot_copy( + Path(str(plan["env_file"])), + env_destination, + expected_sha256=str(plan["env_file_sha256"]), + label="production env file", + ) + ) + + compose_hashes = { + item["path"]: item["sha256"] for item in plan["compose_merge_gate"]["compose_inputs"] + } + compose_destinations: list[Path] = [] + for relative in ( + BASE_COMPOSE_RELATIVE, + PRODUCTION_COMPOSE_RELATIVE, + OFFLINE_COMPOSE_RELATIVE, + ): + destination = snapshot_root.joinpath(*PurePosixPath(relative).parts) + records.append( + _secure_snapshot_copy( + repo_root.joinpath(*PurePosixPath(relative).parts), + destination, + expected_sha256=compose_hashes[relative], + label=f"Compose input {relative}", + ) + ) + compose_destinations.append(destination) + + archive_destinations: dict[str, Path] = {} + for item in plan["docker_import_plan"]: + role = item["role"] + destination = snapshot_root / "docker-archives" / f"{role}.docker.tar" + records.append( + _secure_snapshot_copy( + package.installable_root.joinpath( + *PurePosixPath(item["archive_path"]).parts + ), + destination, + expected_sha256=item["archive_sha256"], + expected_size=item["archive_size_bytes"], + label=f"{role} Docker archive", + ) + ) + archive_destinations[role] = destination + + manifest: dict[str, Any] = { + "schema_version": "yuqei.release-input-snapshot.v1", + "plan_sha256": plan["plan_sha256"], + "files": sorted(records, key=lambda item: item["path"].encode("utf-8")), + } + manifest["snapshot_payload_sha256"] = canonical_sha256(manifest) + _write_exclusive_canonical_json(snapshot_root / "snapshot-manifest.json", manifest) + for directory in { + snapshot_root, + env_destination.parent, + *(path.parent for path in compose_destinations), + *(path.parent for path in archive_destinations.values()), + }: + _fsync_directory(directory) + return SnapshotInputs( + root=snapshot_root, + env_file=env_destination, + compose_files=( + compose_destinations[0], + compose_destinations[1], + compose_destinations[2], + ), + docker_archives=archive_destinations, + manifest=manifest, + ) + + +@contextmanager +def _exclusive_lock(state_dir: Path) -> Iterator[Path]: + _reject_symlink_components(state_dir, label="state directory") + lock_path = state_dir / ".release-package.lock" + try: + descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as exc: + raise ReleasePackageError("Another release package operation holds the exclusive lock.") from exc + except OSError as exc: + raise ReleasePackageError("Unable to acquire the release package lock.") from exc + try: + os.write(descriptor, f"pid={os.getpid()}\n".encode("ascii")) + os.fsync(descriptor) + _fsync_directory(state_dir) + os.close(descriptor) + descriptor = -1 + yield lock_path + finally: + if descriptor >= 0: + os.close(descriptor) + try: + lock_path.unlink(missing_ok=True) + except OSError as exc: # pragma: no cover - an unhealthy filesystem should remain visible. + raise ReleasePackageError("Unable to release the release package lock.") from exc + + +def _receipt_path(state_dir: Path, idempotency_key: str) -> Path: + key_digest = hashlib.sha256(idempotency_key.encode("utf-8")).hexdigest() + return state_dir / "receipts" / f"{key_digest}.json" + + +def _pending_path(state_dir: Path, idempotency_key: str) -> Path: + key_digest = hashlib.sha256(idempotency_key.encode("utf-8")).hexdigest() + return state_dir / "pending" / f"{key_digest}.json" + + +def _pending_journal( + *, + plan: Mapping[str, Any], + idempotency_key: str, + snapshot: SnapshotInputs, + command: list[str], + recorded_at: datetime, +) -> dict[str, Any]: + if recorded_at.tzinfo is None: + raise ReleasePackageError("Pending journal time must include a timezone.") + payload: dict[str, Any] = { + "schema_version": PENDING_SCHEMA, + "status": "reconcile_required", + "phase": "pending_image_import_and_backend_apply", + "operation": plan["operation"], + "deployment_scope": DEPLOYMENT_SCOPE, + "deployed_service_set": plan["deployed_service_set"], + "idempotency_key": idempotency_key, + "recorded_at": recorded_at.astimezone(UTC).isoformat().replace("+00:00", "Z"), + "plan_sha256": plan["plan_sha256"], + "snapshot_payload_sha256": snapshot.manifest["snapshot_payload_sha256"], + "docker_daemon_identity": plan["docker_daemon_identity"], + "installable_manifest_sha256": plan["installable_manifest_sha256"], + "trusted_build_receipt_sha256": plan["trusted_build_receipt_sha256"], + "docker_import_plan_sha256": canonical_sha256(plan["docker_import_plan"]), + "command_argv": command, + } + payload["journal_payload_sha256"] = canonical_sha256(payload) + return payload + + +def _read_pending_journal(path: Path) -> dict[str, Any]: + payload = _read_json(path, label="pending reconciliation journal") + if payload.get("schema_version") != PENDING_SCHEMA: + raise ReleasePackageError("Pending reconciliation journal has an unsupported schema.") + _verify_embedded_hash( + payload, + "journal_payload_sha256", + label="pending reconciliation journal", + ) + if payload.get("status") != "reconcile_required": + raise ReleasePackageError("Pending journal has an invalid status.") + if path.read_bytes() != canonical_json_bytes(payload) + b"\n": + raise ReleasePackageError("Pending reconciliation journal is not canonical JSON.") + return payload + + +def _validate_receipt_payload(payload: Mapping[str, Any], *, label: str) -> None: + expected_fields = { + "schema_version", + "operation", + "status", + "execution_mode", + "deployment_scope", + "deployed_service_set", + "separately_deployed_artifacts", + "identity_bound_image_keys", + "release_completion_claimed", + "release_id", + "target_release_id", + "idempotency_key", + "recorded_at", + "plan_sha256", + "package_manifest_sha256", + "release_manifest_sha256", + "checksum_manifest_sha256", + "image_identity_sha256", + "installable_manifest_sha256", + "trusted_build_receipt_sha256", + "backup_proof_sha256", + "compatibility_proof_sha256", + "docker_daemon_identity", + "production_preflight_summary_sha256", + "compose_merge_gate_summary_sha256", + "offline_execution_gate_summary_sha256", + "snapshot_payload_sha256", + "docker_image_import_result", + "post_apply_check", + "offline", + "pull_policy", + "build_allowed", + "database_downgrade_performed", + "edge_or_dns_change_performed", + "environment_overrides", + "command_argv", + "result", + "receipt_payload_sha256", + } + if set(payload) != expected_fields: + raise ReleasePackageError(f"{label} has unexpected or missing fields.") + if payload.get("schema_version") != RECEIPT_SCHEMA: + raise ReleasePackageError(f"{label} has an unsupported schema.") + _verify_embedded_hash(payload, "receipt_payload_sha256", label=label) + operation = payload.get("operation") + if operation not in CONFIRMATIONS: + raise ReleasePackageError(f"{label} has an invalid operation.") + if payload.get("status") != "backend_verified": + raise ReleasePackageError(f"{label} has an invalid status.") + if payload.get("execution_mode") != "test_injected_runner": + raise ReleasePackageError(f"{label} has an invalid execution mode.") + if payload.get("deployment_scope") != DEPLOYMENT_SCOPE: + raise ReleasePackageError(f"{label} does not declare the backend-only deployment scope.") + expected_services = RUNTIME_SERVICES if operation in {"rollback", "forward"} else INSTALL_SERVICES + if payload.get("deployed_service_set") != list(expected_services): + raise ReleasePackageError(f"{label} has an invalid backend deployed_service_set.") + if payload.get("release_completion_claimed") is not False: + raise ReleasePackageError(f"{label} must not claim completion of the full release.") + if payload.get("identity_bound_image_keys") != list( + APPLICATION_IMAGE_KEYS + INFRASTRUCTURE_IMAGE_KEYS + ): + raise ReleasePackageError(f"{label} does not bind the complete production image inventory.") + separate = payload.get("separately_deployed_artifacts") + if not isinstance(separate, list) or len(separate) != len(SEPARATELY_DEPLOYED_IMAGE_KEYS): + raise ReleasePackageError(f"{label} has an invalid separately deployed artifact inventory.") + for expected_key, artifact in zip(SEPARATELY_DEPLOYED_IMAGE_KEYS, separate, strict=True): + if ( + not isinstance(artifact, dict) + or set(artifact) + != { + "image_key", + "reference", + "digest", + "deployment_scope", + "applied_by_this_operation", + } + or artifact.get("image_key") != expected_key + or artifact.get("deployment_scope") != "separate_web_orchestrator" + or artifact.get("applied_by_this_operation") is not False + or not IMAGE_REFERENCE_RE.fullmatch(str(artifact.get("reference", ""))) + or not IMAGE_DIGEST_RE.fullmatch(str(artifact.get("digest", ""))) + or str(artifact.get("reference", "")).rsplit("@", 1)[-1] + != artifact.get("digest") + ): + raise ReleasePackageError(f"{label} has an invalid separately deployed artifact record.") + + _validate_identifier(payload.get("release_id"), RELEASE_ID_RE, label=f"{label} release_id") + _validate_identifier( + payload.get("target_release_id"), + RELEASE_ID_RE, + label=f"{label} target_release_id", + ) + _validate_identifier( + payload.get("idempotency_key"), + IDEMPOTENCY_RE, + label=f"{label} idempotency_key", + ) + recorded_at = payload.get("recorded_at") + if not isinstance(recorded_at, str) or not recorded_at.endswith("Z"): + raise ReleasePackageError(f"{label} has an invalid recorded_at timestamp.") + try: + parsed_time = datetime.fromisoformat(recorded_at[:-1] + "+00:00") + except ValueError as exc: + raise ReleasePackageError(f"{label} has an invalid recorded_at timestamp.") from exc + if parsed_time.tzinfo is None or parsed_time.utcoffset() != UTC.utcoffset(parsed_time): + raise ReleasePackageError(f"{label} recorded_at must be UTC.") + + for hash_field in ( + "plan_sha256", + "package_manifest_sha256", + "release_manifest_sha256", + "checksum_manifest_sha256", + "image_identity_sha256", + "installable_manifest_sha256", + "trusted_build_receipt_sha256", + "production_preflight_summary_sha256", + "compose_merge_gate_summary_sha256", + "offline_execution_gate_summary_sha256", + "snapshot_payload_sha256", + ): + value = payload.get(hash_field) + if not isinstance(value, str) or not SHA256_RE.fullmatch(value): + raise ReleasePackageError(f"{label} has an invalid {hash_field}.") + for optional_hash_field in ("backup_proof_sha256", "compatibility_proof_sha256"): + value = payload.get(optional_hash_field) + if value is not None and ( + not isinstance(value, str) or not SHA256_RE.fullmatch(value) + ): + raise ReleasePackageError(f"{label} has an invalid {optional_hash_field}.") + + daemon = payload.get("docker_daemon_identity") + daemon_fields = { + "docker_cli", + "docker_host", + "daemon_id", + "daemon_name", + "docker_root_dir", + "server_version", + "identity_sha256", + } + if not isinstance(daemon, dict) or set(daemon) != daemon_fields: + raise ReleasePackageError(f"{label} has an invalid Docker daemon identity.") + _verify_embedded_hash(daemon, "identity_sha256", label=f"{label} Docker daemon identity") + if daemon.get("docker_cli") != str(DOCKER_CLI) or daemon.get("docker_host") != LOCAL_DOCKER_HOST: + raise ReleasePackageError(f"{label} is not bound to the fixed local Docker target.") + if any( + not isinstance(daemon.get(key), str) or not str(daemon.get(key)).strip() + for key in ("daemon_id", "daemon_name", "docker_root_dir", "server_version") + ): + raise ReleasePackageError(f"{label} has an incomplete Docker daemon identity.") + + import_result = payload.get("docker_image_import_result") + if not isinstance(import_result, dict) or set(import_result) != { + "schema_version", + "status", + "images", + "summary_sha256", + }: + raise ReleasePackageError(f"{label} has an invalid Docker image import result.") + _verify_embedded_hash( + import_result, + "summary_sha256", + label=f"{label} Docker image import result", + ) + imported_images = import_result.get("images") + if ( + import_result.get("schema_version") != "yuqei.docker-image-import-result.v1" + or import_result.get("status") != "verified" + or not isinstance(imported_images, list) + or len(imported_images) != len(IMPORT_ROLE_ENV_KEYS) + ): + raise ReleasePackageError(f"{label} has an invalid Docker image import result.") + import_fields = { + "sequence", + "role", + "archive_sha256", + "loaded_image_id", + "offline_compose_override", + "load_stdout_sha256", + "load_stderr_sha256", + } + for sequence, ((role, env_key), record) in enumerate( + zip(IMPORT_ROLE_ENV_KEYS, imported_images, strict=True), + start=1, + ): + if ( + not isinstance(record, dict) + or set(record) != import_fields + or record.get("sequence") != sequence + or record.get("role") != role + or not IMAGE_DIGEST_RE.fullmatch(str(record.get("loaded_image_id", ""))) + or record.get("offline_compose_override") != record.get("loaded_image_id") + or any( + not isinstance(record.get(field), str) + or not SHA256_RE.fullmatch(str(record.get(field))) + for field in ("archive_sha256", "load_stdout_sha256", "load_stderr_sha256") + ) + ): + raise ReleasePackageError(f"{label} has an invalid imported image record.") + + overrides = payload.get("environment_overrides") + expected_override_keys = { + *APPLICATION_IMAGE_KEYS, + "AI_PLATFORM_RELEASE_IMAGE_DIGEST", + } + if operation == "install": + expected_override_keys.update(BACKEND_INSTALL_INFRASTRUCTURE_IMAGE_KEYS) + if not isinstance(overrides, dict) or set(overrides) != expected_override_keys: + raise ReleasePackageError(f"{label} has an invalid environment override set.") + for (_role, env_key), record in zip(IMPORT_ROLE_ENV_KEYS, imported_images, strict=True): + if overrides.get(env_key) != record["loaded_image_id"]: + raise ReleasePackageError(f"{label} does not bind {env_key} to its loaded image ID.") + if not IMAGE_DIGEST_RE.fullmatch(str(overrides.get("AI_PLATFORM_RELEASE_IMAGE_DIGEST", ""))): + raise ReleasePackageError(f"{label} has an invalid AI release image digest.") + for key in BACKEND_INSTALL_INFRASTRUCTURE_IMAGE_KEYS if operation == "install" else (): + if not IMAGE_REFERENCE_RE.fullmatch(str(overrides.get(key, ""))): + raise ReleasePackageError(f"{label} has an invalid infrastructure image override.") + + command = payload.get("command_argv") + if not isinstance(command, list) or any(not isinstance(item, str) for item in command): + raise ReleasePackageError(f"{label} has an invalid command argv.") + _assert_safe_compose_command(command, operation=operation) + + post_check = payload.get("post_apply_check") + expected_post_fields = { + "schema_version", + "status", + "deployment_scope", + "operation", + "target_release_id", + "deployed_service_set", + "daemon_identity_sha256", + "post_check_payload_sha256", + } + if not isinstance(post_check, dict) or set(post_check) != expected_post_fields: + raise ReleasePackageError(f"{label} has an invalid post-apply check.") + _verify_embedded_hash(post_check, "post_check_payload_sha256", label=f"{label} post-apply check") + if ( + post_check.get("schema_version") != POST_APPLY_SCHEMA + or post_check.get("status") != "verified" + or post_check.get("deployment_scope") != DEPLOYMENT_SCOPE + or post_check.get("operation") != operation + or post_check.get("target_release_id") != payload.get("target_release_id") + or post_check.get("deployed_service_set") != payload.get("deployed_service_set") + or post_check.get("daemon_identity_sha256") != daemon.get("identity_sha256") + ): + raise ReleasePackageError(f"{label} has an invalid post-apply check binding.") + + if ( + payload.get("offline") is not True + or payload.get("pull_policy") != "never" + or payload.get("build_allowed") is not False + or payload.get("database_downgrade_performed") is not False + or payload.get("edge_or_dns_change_performed") is not False + ): + raise ReleasePackageError(f"{label} violates offline or destructive-action boundaries.") + result = payload.get("result") + if ( + not isinstance(result, dict) + or set(result) != {"returncode", "stdout_sha256", "stderr_sha256"} + or result.get("returncode") != 0 + or any( + not isinstance(result.get(field), str) + or not SHA256_RE.fullmatch(str(result.get(field))) + for field in ("stdout_sha256", "stderr_sha256") + ) + ): + raise ReleasePackageError(f"{label} has an invalid successful execution result.") + + +def _read_existing_receipt(path: Path) -> dict[str, Any]: + if path.is_symlink(): + raise ReleasePackageError("Idempotency receipt must not be a symbolic link.") + payload = _read_json(path, label="idempotency receipt") + _validate_receipt_payload(payload, label="idempotency receipt") + if path.read_bytes() != canonical_json_bytes(payload) + b"\n": + raise ReleasePackageError("Existing idempotency receipt is not canonical JSON.") + return payload + + +def _write_exclusive_canonical_json(path: Path, payload: Mapping[str, Any]) -> None: + data = canonical_json_bytes(payload) + b"\n" + try: + descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as exc: + raise ReleasePackageError(f"Refusing to overwrite existing receipt: {path}") from exc + try: + with os.fdopen(descriptor, "wb") as handle: + descriptor = -1 + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + _fsync_directory(path.parent) + finally: + if descriptor >= 0: + os.close(descriptor) + + +def _output_bytes(value: object) -> bytes: + if value is None: + return b"" + if isinstance(value, bytes): + return value + return str(value).encode("utf-8", "replace") + + +def _run_application_image_imports( + *, + plan: Mapping[str, Any], + snapshot: SnapshotInputs, + runner: Callable[..., Any], +) -> tuple[bool, dict[str, Any]]: + results: list[dict[str, Any]] = [] + runtime_environment = _fixed_runtime_environment() + for item in plan["docker_import_plan"]: + role = item["role"] + archive = snapshot.docker_archives[role] + if file_sha256(archive) != item["archive_sha256"]: + return False, {"stage": "archive_recheck", "role": role} + load_command = [ + str(DOCKER_CLI), + "--host", + LOCAL_DOCKER_HOST, + "image", + "load", + "--input", + str(archive), + ] + try: + loaded = runner( + load_command, + check=False, + capture_output=True, + shell=False, + env=runtime_environment, + timeout=1800, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, {"stage": "docker_load", "role": role, "error": type(exc).__name__} + loaded_stdout = _output_bytes(getattr(loaded, "stdout", b"")) + loaded_stderr = _output_bytes(getattr(loaded, "stderr", b"")) + if int(loaded.returncode) != 0: + return False, { + "stage": "docker_load", + "role": role, + "returncode": int(loaded.returncode), + "stdout_sha256": hashlib.sha256(loaded_stdout).hexdigest(), + "stderr_sha256": hashlib.sha256(loaded_stderr).hexdigest(), + } + inspect_command = [ + str(DOCKER_CLI), + "--host", + LOCAL_DOCKER_HOST, + "image", + "inspect", + "--format", + "{{.Id}}", + item["expected_repo_tag"], + ] + try: + inspected = runner( + inspect_command, + check=False, + capture_output=True, + shell=False, + env=runtime_environment, + timeout=60, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, {"stage": "docker_inspect", "role": role, "error": type(exc).__name__} + observed_id = _output_bytes(getattr(inspected, "stdout", b"")).decode( + "utf-8", + errors="replace", + ).strip() + if int(inspected.returncode) != 0 or observed_id != item["expected_loaded_image_id"]: + return False, { + "stage": "docker_inspect", + "role": role, + "returncode": int(inspected.returncode), + "expected_loaded_image_id": item["expected_loaded_image_id"], + "observed_image_id_sha256": hashlib.sha256( + observed_id.encode("utf-8") + ).hexdigest(), + } + results.append( + { + "sequence": item["sequence"], + "role": role, + "archive_sha256": item["archive_sha256"], + "loaded_image_id": observed_id, + "offline_compose_override": item["offline_compose_override"], + "load_stdout_sha256": hashlib.sha256(loaded_stdout).hexdigest(), + "load_stderr_sha256": hashlib.sha256(loaded_stderr).hexdigest(), + } + ) + summary: dict[str, Any] = { + "schema_version": "yuqei.docker-image-import-result.v1", + "status": "verified", + "images": results, + } + summary["summary_sha256"] = canonical_sha256(summary) + return True, summary + + +def _validate_post_apply_result( + value: object, + *, + plan: Mapping[str, Any], +) -> dict[str, Any]: + if not isinstance(value, dict): + raise ReleasePackageError("Post-apply checker must return an object.") + _verify_embedded_hash(value, "post_check_payload_sha256", label="post-apply check") + expected = { + "schema_version": POST_APPLY_SCHEMA, + "status": "verified", + "deployment_scope": DEPLOYMENT_SCOPE, + "operation": plan["operation"], + "target_release_id": plan["target_release_id"], + "deployed_service_set": plan["deployed_service_set"], + "daemon_identity_sha256": plan["docker_daemon_identity"]["identity_sha256"], + } + for key, expected_value in expected.items(): + if value.get(key) != expected_value: + raise ReleasePackageError(f"Post-apply check has invalid {key}.") + return dict(value) + + +def _receipt( + *, + plan: Mapping[str, Any], + snapshot: SnapshotInputs, + idempotency_key: str, + command_argv: list[str], + runtime_overrides: Mapping[str, str], + image_import_result: Mapping[str, Any], + post_apply_check: Mapping[str, Any], + snapshot_offline_gate: Mapping[str, Any], + returncode: int, + stdout: bytes, + stderr: bytes, + recorded_at: datetime, +) -> dict[str, Any]: + if recorded_at.tzinfo is None: + raise ReleasePackageError("Receipt time must include a timezone.") + payload: dict[str, Any] = { + "schema_version": RECEIPT_SCHEMA, + "operation": plan["operation"], + "status": "backend_verified", + "execution_mode": "test_injected_runner", + "deployment_scope": plan["deployment_scope"], + "deployed_service_set": plan["deployed_service_set"], + "separately_deployed_artifacts": plan["separately_deployed_artifacts"], + "identity_bound_image_keys": plan["identity_bound_image_keys"], + "release_completion_claimed": False, + "release_id": plan["release_id"], + "target_release_id": plan["target_release_id"], + "idempotency_key": idempotency_key, + "recorded_at": recorded_at.astimezone(UTC).isoformat().replace("+00:00", "Z"), + "plan_sha256": plan["plan_sha256"], + "package_manifest_sha256": plan["package_manifest_sha256"], + "release_manifest_sha256": plan["release_manifest_sha256"], + "checksum_manifest_sha256": plan["checksum_manifest_sha256"], + "image_identity_sha256": plan["image_identity_sha256"], + "installable_manifest_sha256": plan["installable_manifest_sha256"], + "trusted_build_receipt_sha256": plan["trusted_build_receipt_sha256"], + "backup_proof_sha256": plan["backup_proof_sha256"], + "compatibility_proof_sha256": plan["compatibility_proof_sha256"], + "docker_daemon_identity": plan["docker_daemon_identity"], + "production_preflight_summary_sha256": plan["production_preflight"][ + "summary_sha256" + ], + "compose_merge_gate_summary_sha256": plan["compose_merge_gate"]["summary_sha256"], + "offline_execution_gate_summary_sha256": snapshot_offline_gate["summary_sha256"], + "snapshot_payload_sha256": snapshot.manifest["snapshot_payload_sha256"], + "docker_image_import_result": dict(image_import_result), + "post_apply_check": dict(post_apply_check), + "offline": True, + "pull_policy": "never", + "build_allowed": False, + "database_downgrade_performed": False, + "edge_or_dns_change_performed": False, + "environment_overrides": dict(runtime_overrides), + "command_argv": command_argv, + "result": { + "returncode": returncode, + "stdout_sha256": hashlib.sha256(stdout).hexdigest(), + "stderr_sha256": hashlib.sha256(stderr).hexdigest(), + }, + } + payload["receipt_payload_sha256"] = canonical_sha256(payload) + return payload + + +def _require_apply_authorization( + *, + operation: str, + confirmation: str, + idempotency_key: str | None, +) -> str: + expected = confirmation_for(operation) + if confirmation != expected: + raise ReleasePackageError(f"Apply confirmation must exactly equal: {expected}") + if idempotency_key is None or not IDEMPOTENCY_RE.fullmatch(idempotency_key): + raise ReleasePackageError("--idempotency-key is required and must use the safe identifier syntax.") + return idempotency_key + + +def execute( + *, + package_dir: Path, + expected_package_sha256: str, + operation: str = "verify", + env_file: Path | None = None, + apply: bool = False, + confirmation: str = "", + idempotency_key: str | None = None, + state_dir: Path = Path("/var/lib/yuqei/release-package"), + repo_root: Path = REPO_ROOT, + runner: Callable[..., Any] | None = None, + inspection_runner: Callable[..., Any] = subprocess.run, + post_apply_checker: Callable[..., Any] | None = None, + now: datetime | None = None, +) -> ExecutionOutcome: + package = validate_release_package( + package_dir, + expected_package_sha256=expected_package_sha256, + repo_root=repo_root, + ) + if operation == "verify": + if apply: + raise ReleasePackageError("The verify operation is read-only and does not accept --apply.") + return ExecutionOutcome(verification_payload(package)) + if operation not in CONFIRMATIONS: + raise ReleasePackageError("Operation must be verify, install, rollback, or forward.") + if env_file is None: + raise ReleasePackageError("--env-file is required for install, rollback, and forward plans.") + safe_key: str | None = None + if apply: + safe_key = _require_apply_authorization( + operation=operation, + confirmation=confirmation, + idempotency_key=idempotency_key, + ) + if runner is None: + blockers = ", ".join(_real_apply_blockers(operation)) + raise ReleasePackageError( + "Real apply is fail-closed until all release blockers are resolved: " + + blockers + ) + if post_apply_checker is None: + raise ReleasePackageError( + "Injected apply tests require the fixed post-apply checker interface." + ) + if operation == "rollback": + raise ReleasePackageError( + "Rollback apply is fail-closed because no approved rollback Docker import " + "plan exists." + ) + plan = build_plan( + package, + operation=operation, + env_file=env_file, + repo_root=repo_root, + inspection_runner=inspection_runner, + ) + if not apply: + return ExecutionOutcome(plan) + assert safe_key is not None # Authorization above is required for every write path. + + state = _validate_state_target(state_dir, package_root=package.root) + _prepare_secure_state_root(state, require_root_owner=False) + with _exclusive_lock(state): + locked_package = validate_release_package( + package_dir, + expected_package_sha256=expected_package_sha256, + repo_root=repo_root, + ) + locked_plan = build_plan( + locked_package, + operation=operation, + env_file=env_file, + repo_root=repo_root, + inspection_runner=inspection_runner, + ) + if locked_plan["plan_sha256"] != plan["plan_sha256"]: + raise ReleasePackageError("Release package, env, or execution plan changed while acquiring the lock.") + + receipt_path = _receipt_path(state, safe_key) + pending_path = _pending_path(state, safe_key) + receipts_dir = receipt_path.parent + pending_dir = pending_path.parent + if receipts_dir.exists() and (receipts_dir.is_symlink() or not receipts_dir.is_dir()): + raise ReleasePackageError("Receipt directory is unsafe.") + if pending_dir.exists() and (pending_dir.is_symlink() or not pending_dir.is_dir()): + raise ReleasePackageError("Pending journal directory is unsafe.") + receipts_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + pending_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + _reject_symlink_components(receipts_dir, label="receipt directory") + _reject_symlink_components(pending_dir, label="pending journal directory") + if pending_path.exists() or pending_path.is_symlink(): + pending = _read_pending_journal(pending_path) + if pending.get("idempotency_key") != safe_key: + raise ReleasePackageError("Pending reconciliation journal is bound to another key.") + return ExecutionOutcome(pending, exit_code=1, replayed=True) + if receipt_path.exists() or receipt_path.is_symlink(): + existing = _read_existing_receipt(receipt_path) + if existing.get("idempotency_key") != safe_key or existing.get("plan_sha256") != plan["plan_sha256"]: + raise ReleasePackageError("Idempotency key is already bound to a different release operation.") + return ExecutionOutcome(existing, exit_code=0, replayed=True) + + snapshot = _snapshot_inputs( + state_dir=state, + idempotency_key=safe_key, + plan=plan, + package=locked_package, + repo_root=_validated_directory(repo_root, label="repository root"), + ) + snapshot_evidence = _collect_runtime_evidence( + env_file=snapshot.env_file, + repo_root=snapshot.root, + inspection_runner=inspection_runner, + ) + if snapshot_evidence.env_sha256 != plan["env_file_sha256"]: + raise ReleasePackageError("Snapshotted production env SHA-256 does not match the plan.") + if snapshot_evidence.preflight_summary != plan["production_preflight"]: + raise ReleasePackageError("Snapshotted production preflight summary changed.") + if snapshot_evidence.daemon_identity != plan["docker_daemon_identity"]: + raise ReleasePackageError("Docker daemon identity changed before apply.") + + snapshot_offline_gate = _run_offline_execution_gate( + env_file=snapshot.env_file, + repo_root=snapshot.root, + compose_files=snapshot.compose_files, + project_name=plan["compose_project_name"], + offline_application_overrides=plan["offline_application_overrides"], + inspection_runner=inspection_runner, + ) + runtime_overrides = dict(plan["environment_overrides"]) + runtime_overrides.update(plan["offline_application_overrides"]) + runtime_environment = _fixed_runtime_environment(runtime_overrides) + command = _compose_command( + operation=operation, + project_name=plan["compose_project_name"], + env_file=snapshot.env_file, + repo_root=snapshot.root, + compose_files=snapshot.compose_files, + ) + _assert_safe_compose_command(command, operation=operation) + + journal = _pending_journal( + plan=plan, + idempotency_key=safe_key, + snapshot=snapshot, + command=command, + recorded_at=now or datetime.now(UTC), + ) + _write_exclusive_canonical_json(pending_path, journal) + + imported, import_result = _run_application_image_imports( + plan=plan, + snapshot=snapshot, + runner=runner, + ) + if not imported: + return ExecutionOutcome(journal, exit_code=1) + try: + completed = runner( + command, + check=False, + capture_output=True, + shell=False, + env=runtime_environment, + timeout=1800, + ) + returncode = int(completed.returncode) + stdout = _output_bytes(getattr(completed, "stdout", b"")) + stderr = _output_bytes(getattr(completed, "stderr", b"")) + except (OSError, subprocess.TimeoutExpired) as exc: + returncode = 127 + stdout = b"" + stderr = type(exc).__name__.encode("ascii", "replace") + if returncode != 0: + return ExecutionOutcome(journal, exit_code=1) + + post_daemon_identity = _docker_daemon_identity( + runner=inspection_runner, + repo_root=snapshot.root, + ) + if post_daemon_identity != plan["docker_daemon_identity"]: + return ExecutionOutcome(journal, exit_code=1) + try: + post_apply = _validate_post_apply_result( + post_apply_checker( + plan=dict(plan), + command_argv=list(command), + runtime_environment=dict(runtime_environment), + daemon_identity=dict(post_daemon_identity), + ), + plan=plan, + ) + except Exception: + return ExecutionOutcome(journal, exit_code=1) + receipt = _receipt( + plan=plan, + snapshot=snapshot, + idempotency_key=safe_key, + command_argv=command, + runtime_overrides=runtime_overrides, + image_import_result=import_result, + post_apply_check=post_apply, + snapshot_offline_gate=snapshot_offline_gate, + returncode=returncode, + stdout=stdout, + stderr=stderr, + recorded_at=now or datetime.now(UTC), + ) + _write_exclusive_canonical_json(receipt_path, receipt) + try: + pending_path.unlink() + _fsync_directory(pending_dir) + except OSError as exc: + raise ReleasePackageError( + "Backend was verified but pending reconciliation journal could not be cleared." + ) from exc + return ExecutionOutcome(receipt, exit_code=0) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package-dir", type=Path, required=True) + parser.add_argument( + "--expected-package-sha256", + required=True, + help="Externally approved raw SHA-256 of release-package.json.", + ) + parser.add_argument( + "--operation", + choices=("verify", "install", "rollback", "forward"), + default="verify", + help="Defaults to read-only package verification; non-verify operations are plans without --apply.", + ) + parser.add_argument("--env-file", type=Path, help="Absolute protected production env file.") + parser.add_argument("--state-dir", type=Path, default=Path("/var/lib/yuqei/release-package")) + parser.add_argument( + "--apply", + action="store_true", + help=( + "Request execution after authorization and all feature gates; " + "the current production gate is closed." + ), + ) + parser.add_argument( + "--confirmation", + default="", + help="Exact operation confirmation; see the dry-run plan or --help.", + ) + parser.add_argument("--idempotency-key", default=None) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + outcome = execute( + package_dir=args.package_dir, + expected_package_sha256=args.expected_package_sha256, + operation=args.operation, + env_file=args.env_file, + apply=args.apply, + confirmation=args.confirmation, + idempotency_key=args.idempotency_key, + state_dir=args.state_dir, + ) + except (ReleasePackageError, OSError) as exc: + print( + canonical_json_bytes( + {"schema_version": RECEIPT_SCHEMA, "status": "rejected", "error": str(exc)} + ).decode("utf-8"), + file=sys.stderr, + ) + return 2 + print(canonical_json_bytes(outcome.payload).decode("utf-8")) + return outcome.exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/deploy/verify-production-compose.py b/yuqei-infra/deploy/verify-production-compose.py new file mode 100644 index 00000000..be478297 --- /dev/null +++ b/yuqei-infra/deploy/verify-production-compose.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Fail-closed validation of the merged production Compose data model. + +This gate runs only ``docker compose config --format json``. It never starts, +builds, pulls, or removes containers or images. Both the online stack and the +offline stack are rendered for every supported production profile combination +so validation is performed on Compose's merged model rather than on source +YAML text. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCKER_CLI = "/usr/bin/docker" +LOCAL_DOCKER_HOST = "unix:///var/run/docker.sock" +BASE_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.yml" +PRODUCTION_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.production.yml" +OFFLINE_COMPOSE_RELATIVE = "yuqei-infra/compose/docker-compose.production.offline.yml" + +SCHEMA_VERSION = "yuqei.production-compose-merge-gate.v1" +MAX_ENV_BYTES = 1024 * 1024 +MAX_CONFIG_BYTES = 8 * 1024 * 1024 +ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") +IMAGE_REFERENCE_RE = re.compile( + r"^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$" +) + +APPLICATION_SERVICE_IMAGE_ENV: Mapping[str, str] = { + "contract-api-migrate": "CONTRACT_API_IMAGE", + "contract-api": "CONTRACT_API_IMAGE", + "contract-review-worker": "CONTRACT_API_IMAGE", + "contract-playbook-governance-worker": "CONTRACT_API_IMAGE", + "ai-platform-api-migrate": "AI_PLATFORM_API_IMAGE", + "ai-platform-prompt-assets-import": "AI_PLATFORM_API_IMAGE", + "ai-platform-api": "AI_PLATFORM_API_IMAGE", + "worker": "AI_WORKER_IMAGE", + "contract-web": "CONTRACT_WEB_IMAGE", +} + +PRODUCTION_SERVICES = ( + "postgres", + "postgres-init-databases", + "redis", + "minio", + "contract-api-migrate", + "contract-api", + "contract-review-worker", + "contract-playbook-governance-worker", + "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", + "ai-platform-api", + "worker", + "contract-web", + "edge", + "contract-files-backup", + "minio-backup", +) + +PROFILE_ONLY_SERVICES: Mapping[str, str] = { + "contract-web": "backend-hosted-web", + "contract-files-backup": "ops", + "minio-backup": "ops", +} + + +@dataclass(frozen=True) +class ProfileCase: + name: str + profiles: tuple[str, ...] + + +PROFILE_CASES = ( + ProfileCase("default", ()), + ProfileCase("backend-hosted-web", ("backend-hosted-web",)), + ProfileCase("ops", ("ops",)), + ProfileCase("backend-hosted-web+ops", ("backend-hosted-web", "ops")), +) + + +class ComposeGateError(RuntimeError): + """Raised when production Compose validation fails closed.""" + + +Runner = Callable[..., subprocess.CompletedProcess[Any]] + + +def _canonical_json_bytes(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise ComposeGateError(f"Cannot read required file: {path}") from exc + return digest.hexdigest() + + +def _validated_file(path: Path, *, label: str, max_bytes: int | None = None) -> Path: + candidate = Path(path) + if candidate.is_symlink(): + raise ComposeGateError(f"{label} must not be a symbolic link.") + try: + resolved = candidate.resolve(strict=True) + stat = resolved.stat() + except OSError as exc: + raise ComposeGateError(f"{label} is missing or unreadable: {candidate}") from exc + if not resolved.is_file(): + raise ComposeGateError(f"{label} must be a regular file.") + if max_bytes is not None and stat.st_size > max_bytes: + raise ComposeGateError(f"{label} exceeds the maximum allowed size.") + return resolved + + +def _compose_inputs(repo_root: Path) -> tuple[Path, Path, Path]: + try: + root = Path(repo_root).resolve(strict=True) + except OSError as exc: + raise ComposeGateError("Repository root is missing or unreadable.") from exc + if not root.is_dir(): + raise ComposeGateError("Repository root must be a directory.") + + results: list[Path] = [] + for label, relative in ( + ("base Compose file", BASE_COMPOSE_RELATIVE), + ("production Compose file", PRODUCTION_COMPOSE_RELATIVE), + ("offline Compose override", OFFLINE_COMPOSE_RELATIVE), + ): + candidate = root / relative + validated = _validated_file(candidate, label=label, max_bytes=MAX_CONFIG_BYTES) + try: + validated.relative_to(root) + except ValueError as exc: + raise ComposeGateError(f"{label} escapes the repository root.") from exc + results.append(validated) + return results[0], results[1], results[2] + + +def parse_strict_env(path: Path) -> tuple[Path, dict[str, str]]: + target = _validated_file(path, label="production env file", max_bytes=MAX_ENV_BYTES) + try: + text = target.read_text(encoding="utf-8-sig") + except (OSError, UnicodeError) as exc: + raise ComposeGateError("Production env file must be readable UTF-8 text.") from exc + + values: dict[str, str] = {} + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export ") or "=" not in line: + raise ComposeGateError(f"Invalid strict env syntax at line {line_number}.") + raw_key, raw_value = line.split("=", 1) + if raw_key != raw_key.strip(): + raise ComposeGateError(f"Invalid strict env key at line {line_number}.") + key = raw_key + if not ENV_KEY_RE.fullmatch(key) or key in values: + raise ComposeGateError(f"Invalid or duplicate strict env key at line {line_number}.") + values[key] = raw_value.strip() + + project_name = values.get("COMPOSE_PROJECT_NAME") + if project_name is None or not PROJECT_RE.fullmatch(project_name): + raise ComposeGateError("COMPOSE_PROJECT_NAME is missing or unsafe in the strict env file.") + + for env_key in sorted(set(APPLICATION_SERVICE_IMAGE_ENV.values())): + image = values.get(env_key) + if image is None or not IMAGE_REFERENCE_RE.fullmatch(image): + raise ComposeGateError( + f"{env_key} must be an explicit lowercase immutable @sha256 image " + "in the strict env file." + ) + return target, values + + +def _reject_json_constant(value: str) -> None: + raise ComposeGateError(f"Compose JSON contains forbidden numeric constant {value}.") + + +def _json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ComposeGateError(f"Compose JSON contains duplicate key {key!r}.") + result[key] = value + return result + + +def _parse_compose_json(stdout: Any) -> dict[str, Any]: + if isinstance(stdout, bytes): + try: + raw = stdout.decode("utf-8") + except UnicodeError as exc: + raise ComposeGateError("Docker Compose emitted non-UTF-8 JSON.") from exc + elif isinstance(stdout, str): + raw = stdout + else: + raise ComposeGateError("Docker Compose did not emit textual JSON.") + if len(raw.encode("utf-8")) > MAX_CONFIG_BYTES: + raise ComposeGateError("Docker Compose JSON exceeds the maximum allowed size.") + try: + payload = json.loads( + raw, + object_pairs_hook=_json_object, + parse_constant=_reject_json_constant, + ) + except ComposeGateError: + raise + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise ComposeGateError("Docker Compose did not emit valid JSON.") from exc + if not isinstance(payload, dict): + raise ComposeGateError("Docker Compose JSON must be an object.") + return payload + + +def _required_services(profiles: Sequence[str]) -> set[str]: + active = set(profiles) + return { + service + for service in PRODUCTION_SERVICES + if service not in PROFILE_ONLY_SERVICES + or PROFILE_ONLY_SERVICES[service] in active + } + + +def _validate_model( + payload: Mapping[str, Any], + *, + env_values: Mapping[str, str], + mode: str, + profile_case: ProfileCase, +) -> dict[str, Any]: + services = payload.get("services") + if not isinstance(services, dict): + raise ComposeGateError( + f"{mode}/{profile_case.name}: merged Compose model has no services object." + ) + + actual_names = set(services) + allowed_names = set(PRODUCTION_SERVICES) + unexpected = sorted(actual_names - allowed_names) + if unexpected: + raise ComposeGateError( + f"{mode}/{profile_case.name}: unexpected production services: {', '.join(unexpected)}." + ) + missing = sorted(_required_services(profile_case.profiles) - actual_names) + if missing: + raise ComposeGateError( + f"{mode}/{profile_case.name}: required services are missing: {', '.join(missing)}." + ) + + validated_application_services: list[str] = [] + for service_name, env_key in APPLICATION_SERVICE_IMAGE_ENV.items(): + if service_name not in services: + continue + service = services[service_name] + if not isinstance(service, dict): + raise ComposeGateError( + f"{mode}/{profile_case.name}: service {service_name} is not an object." + ) + if service.get("image") != env_values[env_key]: + raise ComposeGateError( + f"{mode}/{profile_case.name}: service {service_name} image does not " + f"exactly match {env_key}." + ) + if service.get("build") is not None: + raise ComposeGateError( + f"{mode}/{profile_case.name}: service {service_name} retains a " + "build definition." + ) + expected_pull = "never" if mode == "offline" else "always" + if service.get("pull_policy") != expected_pull: + raise ComposeGateError( + f"{mode}/{profile_case.name}: service {service_name} pull_policy " + f"must be {expected_pull}." + ) + validated_application_services.append(service_name) + + if mode == "offline": + for service_name in sorted(actual_names): + service = services[service_name] + if not isinstance(service, dict): + raise ComposeGateError( + f"offline/{profile_case.name}: service {service_name} is not an object." + ) + if service.get("build") is not None: + raise ComposeGateError( + f"offline/{profile_case.name}: service {service_name} retains " + "a build definition." + ) + if service.get("pull_policy") != "never": + raise ComposeGateError( + f"offline/{profile_case.name}: service {service_name} " + "pull_policy must be never." + ) + + return { + "mode": mode, + "profile_case": profile_case.name, + "profiles": list(profile_case.profiles), + "service_count": len(actual_names), + "validated_application_services": validated_application_services, + "config_sha256": hashlib.sha256(_canonical_json_bytes(payload)).hexdigest(), + } + + +def _command( + *, + project_name: str, + env_file: Path, + compose_files: Sequence[Path], + profile_case: ProfileCase, +) -> list[str]: + command = [ + str(DOCKER_CLI), + "--host", + LOCAL_DOCKER_HOST, + "compose", + "--project-name", + project_name, + "--env-file", + str(env_file), + ] + for compose_file in compose_files: + command.extend(("-f", str(compose_file))) + for profile in profile_case.profiles: + command.extend(("--profile", profile)) + command.extend(("config", "--format", "json")) + return command + + +def _safe_subprocess_env(env_values: Mapping[str, str]) -> dict[str, str]: + # The env file is the only interpolation source. In particular, an + # ambient Docker context, daemon, Compose file/profile, config directory, + # or executable search path must not redirect this read-only gate. + _ = env_values + return { + "HOME": "/var/empty", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + } + + +def _run_config( + command: list[str], + *, + repo_root: Path, + runner: Runner, + child_env: Mapping[str, str], + label: str, +) -> dict[str, Any]: + if command[:4] != [str(DOCKER_CLI), "--host", LOCAL_DOCKER_HOST, "compose"]: + raise ComposeGateError("Internal error: Docker target is not the fixed local daemon.") + if command[-3:] != ["config", "--format", "json"]: + raise ComposeGateError("Internal error: only Compose config JSON commands are permitted.") + if any(token in {"up", "pull", "build", "down", "run", "exec"} for token in command): + raise ComposeGateError("Internal error: a mutating Compose command was constructed.") + try: + completed = runner( + command, + check=False, + capture_output=True, + text=True, + shell=False, + cwd=str(repo_root), + env=dict(child_env), + timeout=60, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc: + raise ComposeGateError(f"{label}: Docker Compose config could not be executed.") from exc + if completed.returncode != 0: + stderr = completed.stderr + if isinstance(stderr, bytes): + stderr = stderr.decode("utf-8", errors="replace") + detail = str(stderr or "").strip().replace("\r", " ").replace("\n", " ")[:500] + suffix = f": {detail}" if detail else "" + raise ComposeGateError( + f"{label}: Docker Compose config failed with exit code {completed.returncode}{suffix}" + ) + return _parse_compose_json(completed.stdout) + + +def verify_production_compose( + *, + env_file: Path, + repo_root: Path = REPO_ROOT, + runner: Runner = subprocess.run, +) -> dict[str, Any]: + """Render and validate every supported online/offline profile combination.""" + + env_target, env_values = parse_strict_env(env_file) + try: + root = Path(repo_root).resolve(strict=True) + except OSError as exc: + raise ComposeGateError("Repository root is missing or unreadable.") from exc + base, production, offline = _compose_inputs(root) + child_env = _safe_subprocess_env(env_values) + results: list[dict[str, Any]] = [] + + for profile_case in PROFILE_CASES: + for mode, compose_files in ( + ("online", (base, production)), + ("offline", (base, production, offline)), + ): + command = _command( + project_name=env_values["COMPOSE_PROJECT_NAME"], + env_file=env_target, + compose_files=compose_files, + profile_case=profile_case, + ) + payload = _run_config( + command, + repo_root=root, + runner=runner, + child_env=child_env, + label=f"{mode}/{profile_case.name}", + ) + results.append( + _validate_model( + payload, + env_values=env_values, + mode=mode, + profile_case=profile_case, + ) + ) + + return { + "schema_version": SCHEMA_VERSION, + "status": "passed", + "env_file_sha256": _sha256(env_target), + "compose_inputs": [ + {"path": path.relative_to(root).as_posix(), "sha256": _sha256(path)} + for path in (base, production, offline) + ], + "application_service_count": len(APPLICATION_SERVICE_IMAGE_ENV), + "production_service_count": len(PRODUCTION_SERVICES), + "cases": results, + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Read-only gate for merged online/offline production Compose models." + ) + parser.add_argument("--env-file", required=True, type=Path) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + report = verify_production_compose(env_file=args.env_file) + except ComposeGateError as exc: + print(f"production Compose gate failed: {exc}", file=sys.stderr) + return 1 + print(_canonical_json_bytes(report).decode("utf-8")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/docker/contract-web.Dockerfile b/yuqei-infra/docker/contract-web.Dockerfile index f340c0a5..065462d3 100644 --- a/yuqei-infra/docker/contract-web.Dockerfile +++ b/yuqei-infra/docker/contract-web.Dockerfile @@ -18,7 +18,24 @@ COPY yuqei-contract-platform/packages/design-system yuqei-contract-platform/pack RUN npm --workspace @yuqei/contract-web run build +# npm is required only while assembling the Next.js bundle. Do not ship the +# package-manager CLI (and its independent dependency tree) in the runtime +# image; start the already installed Next.js CLI with Node directly instead. +RUN rm -rf \ + /usr/local/lib/node_modules/npm \ + /usr/local/lib/node_modules/corepack \ + /usr/local/bin/npm \ + /usr/local/bin/npx \ + /usr/local/bin/corepack \ + /usr/local/bin/yarn \ + /usr/local/bin/yarnpkg \ + /usr/local/bin/pnpm \ + /opt/yarn* \ + && chown -R node:node /app + ENV NODE_ENV=production +USER node + EXPOSE 3000 -CMD ["npm", "--workspace", "@yuqei/contract-web", "run", "start"] +CMD ["node", "/app/node_modules/next/dist/bin/next", "start", "/app/yuqei-contract-platform/apps/contract-web", "-p", "3000"] diff --git a/yuqei-infra/release/linux-release-security-policy.v1.json b/yuqei-infra/release/linux-release-security-policy.v1.json new file mode 100644 index 00000000..d1a62037 --- /dev/null +++ b/yuqei-infra/release/linux-release-security-policy.v1.json @@ -0,0 +1,309 @@ +{ + "schema_version": "yuqei.linux-release-security-policy.v1", + "target": { + "architecture": "amd64", + "os": "linux", + "platform": "linux/amd64" + }, + "builder": { + "allowed_drivers": [ + "docker-container" + ], + "maximum_nodes": 1, + "required_buildkit_version": "0.13.2", + "required_driver_image": "docker.m.daocloud.io/moby/buildkit@sha256:9194b5ec1be368f41c516df7f93f7f540630ea06136056b2ffebb62226ed4ad6", + "required_driver_image_digest": "sha256:9194b5ec1be368f41c516df7f93f7f540630ea06136056b2ffebb62226ed4ad6", + "required_exporters": [ + "oci", + "docker" + ], + "required_local_driver_image_id": "sha256:1a21071780b069d68bd7ef277675f1c5273fee488cec145494cf072777260797", + "required_platform": "linux/amd64" + }, + "compose_gate": { + "case_count": 8, + "compose_inputs": [ + "yuqei-infra/compose/docker-compose.yml", + "yuqei-infra/compose/docker-compose.production.yml", + "yuqei-infra/compose/docker-compose.production.offline.yml" + ], + "production_secrets_forbidden": true, + "schema_version": "yuqei.production-compose-merge-gate.v1", + "synthetic_environment_required": true, + "validator": "yuqei-infra/deploy/verify-production-compose.py" + }, + "execution": { + "command_timeouts_seconds": { + "base_image_inspect": 120, + "builder_inspect": 120, + "compose_gate": 600, + "container_inspect": 30, + "git_validation": 30, + "image_build": 3600, + "sbom_generation": 600, + "tool_version": 30, + "trivy_db_download": 600, + "trivy_db_inspect": 120, + "vulnerability_scan": 900 + }, + "controlled_environment": { + "BUILDX_CONFIG": "/root/.docker/buildx", + "DOCKER_CONFIG": "/var/empty/docker", + "HOME": "/var/empty", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PATH": "/usr/bin:/bin" + }, + "docker_host": "unix:///var/run/docker.sock", + "pinned_executables": { + "docker": "/usr/bin/docker", + "git": "/usr/bin/git", + "syft": "/usr/bin/syft", + "trivy": "/usr/bin/trivy" + } + }, + "toolchain": { + "buildx": { + "allowed_versions": [ + "0.14.0" + ] + }, + "compose": { + "allowed_versions": [ + "2.27.0" + ] + }, + "docker_cli": { + "allowed_versions": [ + "26.1.3" + ] + }, + "docker_engine": { + "allowed_versions": [ + "26.1.3" + ] + }, + "syft": { + "allowed_versions": [ + "1.52.0" + ] + }, + "trivy": { + "allowed_versions": [ + "0.74.0" + ] + } + }, + "images": [ + { + "base_image": { + "build_arg": "NODE_BASE_IMAGE", + "content_digest": "sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50", + "source_reference": "node:22-alpine@sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50", + "transport": "domestic_registry_mirror", + "transport_reference": "docker.m.daocloud.io/library/node:22-alpine@sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50" + }, + "dockerfile": "yuqei-infra/docker/contract-web.Dockerfile", + "environment_key": "CONTRACT_WEB_IMAGE", + "name": "yuqei/contract-web", + "role": "contract-web" + }, + { + "base_image": { + "build_arg": "PYTHON_BASE_IMAGE", + "content_digest": "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", + "source_reference": "python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", + "transport": "domestic_registry_mirror", + "transport_reference": "docker.m.daocloud.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b" + }, + "dockerfile": "yuqei-infra/docker/contract-api.Dockerfile", + "environment_key": "CONTRACT_API_IMAGE", + "name": "yuqei/contract-api", + "role": "contract-api" + }, + { + "base_image": { + "build_arg": "PYTHON_BASE_IMAGE", + "content_digest": "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", + "source_reference": "python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", + "transport": "domestic_registry_mirror", + "transport_reference": "docker.m.daocloud.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b" + }, + "dockerfile": "yuqei-infra/docker/ai-platform-api.Dockerfile", + "environment_key": "AI_PLATFORM_API_IMAGE", + "name": "yuqei/ai-platform-api", + "role": "ai-platform-api" + }, + { + "base_image": { + "build_arg": "PYTHON_BASE_IMAGE", + "content_digest": "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", + "source_reference": "python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", + "transport": "domestic_registry_mirror", + "transport_reference": "docker.m.daocloud.io/library/python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b" + }, + "dockerfile": "yuqei-infra/docker/worker.Dockerfile", + "environment_key": "AI_WORKER_IMAGE", + "name": "yuqei/ai-worker", + "role": "ai-worker" + } + ], + "sbom": { + "format": "spdx-json", + "generator": "syft", + "spdx_version": "SPDX-2.3" + }, + "vulnerability_scan": { + "database": { + "fail_closed": true, + "maximum_age_hours": 24, + "repository_transport": { + "allowed_media_types": [ + "application/vnd.oci.image.manifest.v1+json" + ], + "reference": "docker.m.daocloud.io/aquasec/trivy-db:2", + "schema_version": 2, + "transport": "domestic_registry_mirror" + }, + "require_next_update_after_scan": true + }, + "exemptions": [ + { + "id": "RC04.4-DEBIAN13-PYTHON-UNFIXED-HIGH-001", + "expires_at": "2026-10-20T00:00:00Z", + "scope": { + "roles": [ + "contract-api", + "ai-platform-api", + "ai-worker" + ], + "class": "os-pkgs", + "type": "debian", + "os_family": "debian", + "severity": "HIGH", + "fixed_version_must_be_empty": true + }, + "findings": [ + { + "vulnerability_id": "CVE-2025-69720", + "package_names": [ + "libncursesw6", + "libtinfo6", + "ncurses-base", + "ncurses-bin" + ], + "installed_version": "6.5+20250216-2" + }, + { + "vulnerability_id": "CVE-2026-16742", + "package_names": [ + "libsystemd0", + "libudev1" + ], + "installed_version": "257.13-1~deb13u1" + }, + { + "vulnerability_id": "CVE-2026-54369", + "package_names": [ + "libacl1" + ], + "installed_version": "2.3.2-2+b1" + }, + { + "vulnerability_id": "CVE-2026-76642", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux" + ], + "installed_version": "1:2.41.5-0+deb13u1" + }, + { + "vulnerability_id": "CVE-2026-78408", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux" + ], + "installed_version": "1:2.41.5-0+deb13u1" + }, + { + "vulnerability_id": "CVE-2026-78409", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux" + ], + "installed_version": "1:2.41.5-0+deb13u1" + }, + { + "vulnerability_id": "CVE-2026-78410", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux" + ], + "installed_version": "1:2.41.5-0+deb13u1" + }, + { + "vulnerability_id": "CVE-2026-9538", + "package_names": [ + "perl-base" + ], + "installed_version": "5.40.1-6+deb13u1" + } + ] + } + ], + "fail_closed_on": [ + "database_download_failure", + "database_metadata_missing", + "database_expired", + "database_too_old", + "scanner_execution_failure", + "scan_output_missing", + "json_parse_failure", + "schema_validation_failure", + "waiver_expired", + "waiver_scope_mismatch", + "waiver_finding_mismatch" + ], + "format": "json", + "ignore_unfixed": false, + "scanner": "trivy", + "scanners": [ + "vuln" + ], + "thresholds": { + "CRITICAL": 0, + "HIGH": 0 + } + }, + "unsupported_tools": { + "skopeo": "not_required_and_must_not_be_used_by_this_pipeline" + } +} diff --git a/yuqei-infra/scripts/build-installable-release.py b/yuqei-infra/scripts/build-installable-release.py new file mode 100644 index 00000000..56d33c6d --- /dev/null +++ b/yuqei-infra/scripts/build-installable-release.py @@ -0,0 +1,977 @@ +#!/usr/bin/env python3 +"""Validate a Linux build bundle and emit its canonical installable manifest. + +The assembler copies nothing and trusts no receipt claim without re-reading the +referenced bytes. It refuses missing/extra roles, duplicate identities or +paths, digest drift, traversal, symlinks, stale scanner metadata, and any +non-passing vulnerability report. +""" + +from __future__ import annotations + +import argparse +import errno +import hashlib +import json +import os +import re +import stat +import sys +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from linux_release_common import ( # noqa: E402 + COMMAND_TIMEOUTS_SECONDS, + COMPOSE_GATE_POLICY, + CONTROLLED_SUBPROCESS_ENV, + INSTALLABLE_MANIFEST_SCHEMA, + LOCAL_DOCKER_HOST, + PINNED_EXECUTABLES, + REQUIRED_IMAGE_SPECS, + REQUIRED_ROLES, + REQUIRED_TOOL_VERSIONS, + TARGET, + TRIVY_DB_REPOSITORY_TRANSPORT, + ReleaseValidationError, + canonical_json_bytes, + ensure_regular_file_under, + file_record, + inspect_docker_archive, + inspect_oci_archive, + load_json_file, + load_policy, + load_source_manifest, + parse_rfc3339, + validate_receipt_payload_digest, + validate_compose_gate_report, + validate_candidate_checkout, + validate_manifest_commit_tree, + validate_relative_path, + validate_spdx, + validate_tag_binding, + validate_trivy_db_metadata, + validate_trivy_scan, + verify_file_record, + verify_source_archive, + synthetic_compose_gate_env_bytes, + with_payload_digest, +) + + +RELEASE_CONTROL_PATHS = { + "runner": "yuqei-infra/scripts/run-linux-release-build.py", + "common": "yuqei-infra/scripts/linux_release_common.py", + "policy": "yuqei-infra/release/linux-release-security-policy.v1.json", +} + + +def _mapping(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ReleaseValidationError(f"{label} must be a JSON object") + return value + + +def _list(value: Any, label: str) -> list[Any]: + if not isinstance(value, list): + raise ReleaseValidationError(f"{label} must be a JSON array") + return value + + +def _record_path(record: Any, label: str) -> str: + value = _mapping(record, label).get("path") + if not isinstance(value, str): + raise ReleaseValidationError(f"{label}.path must be a string") + return value + + +def _compare_summary(record: dict[str, Any], summary: dict[str, Any], *, label: str) -> None: + drift = { + key: {"receipt": record.get(key), "actual": value} + for key, value in summary.items() + if record.get(key) != value + } + if drift: + raise ReleaseValidationError(f"{label} parsed metadata drift: {json.dumps(drift, sort_keys=True)}") + + +def _path_relative_to_root(root: Path, path: Path, *, label: str) -> str: + try: + relative = path.resolve(strict=True).relative_to(root.resolve(strict=True)) + except (OSError, ValueError) as error: + raise ReleaseValidationError(f"{label} must be within the artifact root") from error + result = relative.as_posix() + ensure_regular_file_under(root, result) + return result + + +def _reject_unlisted_entries(root: Path, expected_files: set[str], output: Path) -> None: + output_relative = None + try: + output_relative = output.resolve(strict=False).relative_to(root.resolve(strict=True)).as_posix() + except (OSError, ValueError): + pass + actual_files: set[str] = set() + for path in root.rglob("*"): + relative = path.relative_to(root).as_posix() + if path.is_symlink(): + raise ReleaseValidationError(f"installable artifact tree contains a symlink: {relative}") + if path.is_file(): + actual_files.add(relative) + elif not path.is_dir(): + raise ReleaseValidationError(f"installable artifact tree contains a special entry: {relative}") + if output_relative: + actual_files.discard(output_relative) + if actual_files != expected_files: + raise ReleaseValidationError( + "artifact tree differs from the receipt: " + f"missing={sorted(expected_files - actual_files)}, unlisted={sorted(actual_files - expected_files)}" + ) + + +def _write_exclusive_manifest(output: Path, payload: dict[str, Any]) -> None: + """Durably create an owner-only manifest without following or replacing paths.""" + + parent = output.parent + if parent.is_symlink() or not parent.is_dir(): + raise ReleaseValidationError( + "installable manifest parent must be a real directory" + ) + if os.name != "nt": + parent_metadata = parent.stat() + if ( + parent_metadata.st_uid != os.geteuid() + or stat.S_IMODE(parent_metadata.st_mode) & 0o022 + ): + raise ReleaseValidationError( + "installable manifest parent must be owned by the executor and not group/world writable" + ) + + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = -1 + try: + descriptor = os.open(output, flags, 0o600) + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1: + raise ReleaseValidationError( + "installable manifest output must be a single-link regular file" + ) + data = canonical_json_bytes(payload) + view = memoryview(data) + while view: + written = os.write(descriptor, view) + if written <= 0: + raise OSError("short write while creating installable manifest") + view = view[written:] + if os.name != "nt": + os.fchmod(descriptor, 0o600) + os.fsync(descriptor) + final = os.fstat(descriptor) + if not stat.S_ISREG(final.st_mode) or final.st_nlink != 1: + raise ReleaseValidationError( + "installable manifest output changed identity while being written" + ) + try: + path_identity = os.lstat(output) + except OSError as error: + raise ReleaseValidationError( + "installable manifest output path disappeared while being written" + ) from error + if ( + not stat.S_ISREG(path_identity.st_mode) + or path_identity.st_nlink != 1 + or path_identity.st_dev != final.st_dev + or path_identity.st_ino != final.st_ino + ): + raise ReleaseValidationError( + "installable manifest output path changed identity while being written" + ) + except FileExistsError as error: + raise ReleaseValidationError( + "installable manifest output must not already exist" + ) from error + except OSError as error: + if error.errno == errno.ELOOP: + raise ReleaseValidationError( + "installable manifest output must not be a symbolic link" + ) from error + raise + finally: + if descriptor >= 0: + os.close(descriptor) + if os.name != "nt": + directory_descriptor = os.open( + output.parent, + os.O_RDONLY | getattr(os, "O_DIRECTORY", 0), + ) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) + + +def assemble_installable_release( + *, + artifact_root: Path, + receipt_path: Path, + expected_receipt_sha256: str, + repo: Path, + output: Path, + write_output: bool = True, +) -> dict[str, Any]: + if artifact_root.is_symlink() or not artifact_root.is_dir(): + raise ReleaseValidationError("artifact root must be a real directory") + receipt_relative = _path_relative_to_root(artifact_root, receipt_path, label="build receipt") + receipt_bytes = receipt_path.read_bytes() + if not re.fullmatch(r"[0-9a-f]{64}", expected_receipt_sha256): + raise ReleaseValidationError("expected build receipt raw SHA-256 is invalid") + if hashlib.sha256(receipt_bytes).hexdigest() != expected_receipt_sha256: + raise ReleaseValidationError( + "build receipt does not match the independently supplied raw SHA-256" + ) + receipt = validate_receipt_payload_digest(load_json_file(receipt_path, label="build receipt")) + if canonical_json_bytes(receipt) != receipt_bytes: + raise ReleaseValidationError("build receipt is not serialized as canonical JSON") + + policy_record = _mapping(receipt.get("policy"), "receipt policy") + policy_path = verify_file_record(artifact_root, policy_record, label="receipt policy") + policy = load_policy(policy_path) + if policy_record.get("schema_version") != policy["schema_version"] or policy_record.get("target") != TARGET: + raise ReleaseValidationError("receipt policy metadata drift") + + candidate = _mapping(receipt.get("candidate"), "receipt candidate") + required_candidate = { + "commit", + "tag", + "tag_object_oid", + "tree", + "commit_tree", + "commit_timestamp", + "source_manifest_payload_sha256", + "source_manifest", + "source_archive", + } + if set(candidate) != required_candidate: + raise ReleaseValidationError("receipt candidate fields are missing or unknown") + source_manifest_path = verify_file_record( + artifact_root, candidate["source_manifest"], label="source manifest" + ) + source_manifest = load_source_manifest(source_manifest_path, commit=candidate["commit"]) + if source_manifest["manifest_payload_sha256"] != candidate["source_manifest_payload_sha256"]: + raise ReleaseValidationError("source manifest payload digest drift in build receipt") + if source_manifest["candidate"]["tree"] != candidate["tree"]: + raise ReleaseValidationError("source manifest tree drift in build receipt") + if source_manifest["candidate"]["commit_timestamp"] != candidate["commit_timestamp"]: + raise ReleaseValidationError("source manifest timestamp drift in build receipt") + source_archive_path = verify_file_record( + artifact_root, candidate["source_archive"], label="source archive" + ) + archive_summary = verify_source_archive(source_archive_path, source_manifest) + for key in ("root_prefix", "file_count"): + if candidate["source_archive"].get(key) != archive_summary[key]: + raise ReleaseValidationError(f"source archive {key} drift in build receipt") + commit_tree = validate_manifest_commit_tree( + repo, commit=candidate["commit"], manifest=source_manifest + ) + if candidate["commit_tree"] != commit_tree: + raise ReleaseValidationError("candidate commit-tree evidence drift in build receipt") + tag_binding = validate_tag_binding( + repo, + tag=candidate["tag"], + commit=candidate["commit"], + expected_tree=candidate["tree"], + ) + if candidate["tag_object_oid"] != tag_binding["tag_object_oid"]: + raise ReleaseValidationError("annotated tag object drift in build receipt") + + builder = _mapping(receipt.get("builder"), "receipt builder") + required_builder_fields = { + "os", + "architecture", + "target_platform", + "tools", + "executables", + "controlled_environment", + "docker_host", + "command_timeouts_seconds", + "buildx_builder", + "base_image_transports", + "candidate_checkout", + "release_controls", + "skopeo_used", + } + if set(builder) != required_builder_fields: + raise ReleaseValidationError("receipt builder fields are missing or unknown") + if builder.get("os") != "linux" or builder.get("architecture") != "amd64": + raise ReleaseValidationError("build receipt was not produced by a Linux amd64 builder") + if builder.get("target_platform") != TARGET["platform"]: + raise ReleaseValidationError("build receipt target platform drift") + if builder.get("tools") != REQUIRED_TOOL_VERSIONS: + raise ReleaseValidationError("build receipt tool versions are not the pinned toolchain") + if builder.get("command_timeouts_seconds") != COMMAND_TIMEOUTS_SECONDS: + raise ReleaseValidationError("build receipt external-command timeout contract drift") + if builder.get("controlled_environment") != CONTROLLED_SUBPROCESS_ENV: + raise ReleaseValidationError("build receipt controlled subprocess environment drift") + if builder.get("docker_host") != LOCAL_DOCKER_HOST: + raise ReleaseValidationError("build receipt Docker daemon endpoint drift") + candidate_checkout = _mapping( + builder.get("candidate_checkout"), "receipt candidate checkout" + ) + if set(candidate_checkout) != { + "head", + "worktree_clean", + "replace_refs_present", + "status_sha256", + }: + raise ReleaseValidationError( + "receipt candidate checkout fields are missing or unknown" + ) + actual_checkout = validate_candidate_checkout(repo, commit=candidate["commit"]) + if candidate_checkout != actual_checkout: + raise ReleaseValidationError("receipt candidate checkout evidence drift") + + source_records = { + item["path"]: item for item in source_manifest["files"]["items"] + } + release_controls = _mapping( + builder.get("release_controls"), "receipt release controls" + ) + if set(release_controls) != set(RELEASE_CONTROL_PATHS): + raise ReleaseValidationError("receipt release control inventory drift") + for name, relative in RELEASE_CONTROL_PATHS.items(): + record = _mapping(release_controls[name], f"receipt release control {name}") + if set(record) != {"path", "size_bytes", "sha256", "git_blob_oid"}: + raise ReleaseValidationError( + f"receipt release control fields are missing or unknown for {name}" + ) + source_record = source_records.get(relative) + expected_record = ( + { + "path": relative, + "size_bytes": source_record["size"], + "sha256": source_record["sha256"], + "git_blob_oid": source_record["git_blob_oid"], + } + if isinstance(source_record, dict) + else None + ) + if record != expected_record: + raise ReleaseValidationError( + f"receipt release control source binding drift for {name}" + ) + executable_records = _mapping(builder.get("executables"), "receipt executables") + if set(executable_records) != set(PINNED_EXECUTABLES): + raise ReleaseValidationError("build receipt pinned executable inventory drift") + for name, expected_path in PINNED_EXECUTABLES.items(): + executable = _mapping(executable_records[name], f"receipt executable {name}") + if ( + executable.get("path") != expected_path + or executable.get("owner_uid") != 0 + or executable.get("mode") != "0755" + or not isinstance(executable.get("size_bytes"), int) + or isinstance(executable.get("size_bytes"), bool) + or executable["size_bytes"] <= 0 + or not isinstance(executable.get("sha256"), str) + or not re.fullmatch(r"[0-9a-f]{64}", executable["sha256"]) + ): + raise ReleaseValidationError(f"build receipt executable evidence drift for {name}") + buildx_builder = _mapping(builder.get("buildx_builder"), "receipt Buildx builder") + if ( + buildx_builder.get("driver") not in policy["builder"]["allowed_drivers"] + or buildx_builder.get("node_status") != "running" + or buildx_builder.get("exporters") != policy["builder"]["required_exporters"] + or len(_list(buildx_builder.get("platforms"), "Buildx builder platforms")) == 0 + or policy["builder"]["required_platform"] not in buildx_builder["platforms"] + ): + raise ReleaseValidationError("build receipt does not prove the required Buildx builder capabilities") + if buildx_builder.get("buildkit_version") != policy["builder"]["required_buildkit_version"]: + raise ReleaseValidationError("build receipt BuildKit version drift") + for receipt_key, policy_key in ( + ("driver_image", "required_driver_image"), + ("driver_image_digest", "required_driver_image_digest"), + ("local_driver_image_id", "required_local_driver_image_id"), + ): + if buildx_builder.get(receipt_key) != policy["builder"][policy_key]: + raise ReleaseValidationError(f"build receipt {receipt_key} drift") + if buildx_builder.get("image_transport") != "domestic_registry_mirror": + raise ReleaseValidationError("BuildKit driver image transport is not the pinned domestic mirror") + config_root = _mapping(buildx_builder.get("config_root"), "receipt Buildx config root") + if ( + config_root.get("path") != CONTROLLED_SUBPROCESS_ENV["BUILDX_CONFIG"] + or config_root.get("selected_builder_instance") + != f"instances/{buildx_builder.get('name')}" + or config_root.get("root_owned") is not True + or config_root.get("group_or_other_writable") is not False + or config_root.get("symlinks_allowed") is not False + or not isinstance(config_root.get("entry_count"), int) + or isinstance(config_root.get("entry_count"), bool) + or config_root["entry_count"] < 1 + or not isinstance(config_root.get("metadata_sha256"), str) + or not re.fullmatch(r"[0-9a-f]{64}", config_root["metadata_sha256"]) + ): + raise ReleaseValidationError("build receipt fixed Buildx config evidence drift") + if not isinstance(buildx_builder.get("inspect_sha256"), str) or not re.fullmatch( + r"[0-9a-f]{64}", buildx_builder["inspect_sha256"] + ): + raise ReleaseValidationError("build receipt has no Buildx inspection digest") + if not isinstance(buildx_builder.get("container_inspect_sha256"), str) or not re.fullmatch( + r"[0-9a-f]{64}", buildx_builder["container_inspect_sha256"] + ): + raise ReleaseValidationError("build receipt has no BuildKit container inspection digest") + transport_records = _list( + builder.get("base_image_transports"), "receipt base-image transports" + ) + expected_transports = { + image["base_image"]["transport_reference"]: image["base_image"]["content_digest"] + for image in policy["images"] + } + if len(transport_records) != len(expected_transports): + raise ReleaseValidationError("base-image transport evidence is missing or duplicated") + observed_transports: set[str] = set() + for record in transport_records: + item = _mapping(record, "base-image transport record") + reference = item.get("transport_reference") + if reference in observed_transports or expected_transports.get(reference) != item.get("content_digest"): + raise ReleaseValidationError("base-image transport reference or digest drift") + observed_transports.add(reference) + if ( + item.get("transport") != "domestic_registry_mirror" + or item.get("media_type") != "application/vnd.oci.image.manifest.v1+json" + or not isinstance(item.get("inspect_sha256"), str) + or not re.fullmatch(r"[0-9a-f]{64}", item["inspect_sha256"]) + ): + raise ReleaseValidationError("base-image mirror inspection evidence is invalid") + if observed_transports != set(expected_transports): + raise ReleaseValidationError("base-image transport evidence does not cover the policy") + if builder.get("skopeo_used") is not False: + raise ReleaseValidationError("Skopeo is outside the supported release pipeline") + + build = _mapping(receipt.get("build"), "receipt build") + if build.get("parallelism") != 1 or build.get("image_order") != list(REQUIRED_ROLES): + raise ReleaseValidationError("four images were not recorded in the required serial order") + started = parse_rfc3339(build.get("started_at"), label="build.started_at") + completed = parse_rfc3339(build.get("completed_at"), label="build.completed_at") + if completed < started: + raise ReleaseValidationError("build completion precedes build start") + if build.get("source_date_epoch") != int( + parse_rfc3339(candidate["commit_timestamp"], label="candidate.commit_timestamp").timestamp() + ): + raise ReleaseValidationError("SOURCE_DATE_EPOCH is not bound to the candidate commit timestamp") + compose_gate = _mapping(build.get("compose_gate"), "receipt production Compose gate") + if ( + set(compose_gate) + != { + "synthetic_environment", + "production_secrets_used", + "execution_engine", + "validator", + "environment", + "report", + } + or compose_gate.get("synthetic_environment") is not True + or compose_gate.get("production_secrets_used") is not False + or compose_gate.get("execution_engine") + != "self_bound_release_runner_builtin_v1" + ): + raise ReleaseValidationError( + "production Compose gate fields or self-bound execution engine drifted" + ) + compose_validator = _mapping( + compose_gate.get("validator"), "receipt production Compose gate validator" + ) + expected_validator = source_records.get(COMPOSE_GATE_POLICY["validator"]) + if ( + expected_validator is None + or compose_validator.get("source_path") != COMPOSE_GATE_POLICY["validator"] + or compose_validator.get("sha256") != expected_validator["sha256"] + or compose_validator.get("size_bytes") != expected_validator["size"] + ): + raise ReleaseValidationError("production Compose gate validator source binding drift") + compose_env_record = _mapping( + compose_gate.get("environment"), "receipt synthetic Compose gate environment" + ) + compose_env_path = verify_file_record( + artifact_root, + compose_env_record, + label="synthetic Compose gate environment", + ) + expected_compose_env = synthetic_compose_gate_env_bytes() + if compose_env_path.read_bytes() != expected_compose_env: + raise ReleaseValidationError("synthetic Compose gate environment content drift") + compose_report_record = _mapping( + compose_gate.get("report"), "receipt production Compose gate report" + ) + compose_report_path = verify_file_record( + artifact_root, + compose_report_record, + label="production Compose gate report", + ) + compose_report_bytes = compose_report_path.read_bytes() + compose_report_payload = load_json_file( + compose_report_path, label="production Compose gate report" + ) + if canonical_json_bytes(compose_report_payload) != compose_report_bytes: + raise ReleaseValidationError("production Compose gate report is not canonical JSON") + compose_report_summary = validate_compose_gate_report( + compose_report_payload, + policy, + expected_env_sha256=compose_env_record["sha256"], + source_file_sha256={path: item["sha256"] for path, item in source_records.items()}, + ) + _compare_summary( + compose_report_record, + compose_report_summary, + label="production Compose gate report", + ) + + security = _mapping(receipt.get("security"), "receipt security") + if security.get("passed") is not True or security.get("fail_closed") is not True: + raise ReleaseValidationError("build receipt security gate did not pass fail closed") + if security.get("thresholds") != policy["vulnerability_scan"]["thresholds"]: + raise ReleaseValidationError("receipt vulnerability threshold drift") + if security.get("exemptions") != policy["vulnerability_scan"]["exemptions"]: + raise ReleaseValidationError("receipt vulnerability waiver policy drift") + claimed_vulnerability_counts = _mapping( + security.get("vulnerability_counts"), "receipt vulnerability counts" + ) + claimed_waiver_ids = _list(security.get("waiver_ids"), "receipt waiver IDs") + database_record = _mapping(security.get("database"), "receipt Trivy database") + database_path = verify_file_record( + artifact_root, database_record, label="Trivy database metadata" + ) + validated_at = parse_rfc3339(database_record.get("validated_at"), label="database.validated_at") + database_summary = validate_trivy_db_metadata( + load_json_file(database_path, label="Trivy database metadata"), + policy, + at=validated_at, + ) + _compare_summary(database_record, database_summary, label="Trivy database") + repository_transport = _mapping( + database_record.get("repository_transport"), + "receipt Trivy database repository transport", + ) + if ( + repository_transport.get("reference") + != TRIVY_DB_REPOSITORY_TRANSPORT["reference"] + or repository_transport.get("transport") + != TRIVY_DB_REPOSITORY_TRANSPORT["transport"] + or repository_transport.get("schema_version") + != TRIVY_DB_REPOSITORY_TRANSPORT["schema_version"] + or repository_transport.get("media_type") + not in TRIVY_DB_REPOSITORY_TRANSPORT["allowed_media_types"] + or repository_transport.get("stable_during_download") is not True + ): + raise ReleaseValidationError("Trivy database repository transport evidence drift") + resolved_digest = repository_transport.get("resolved_digest") + if not isinstance(resolved_digest, str) or not re.fullmatch( + r"sha256:[0-9a-f]{64}", resolved_digest + ): + raise ReleaseValidationError("Trivy database repository resolved digest is invalid") + for field in ( + "before_download_inspect_sha256", + "after_download_inspect_sha256", + ): + value = repository_transport.get(field) + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value): + raise ReleaseValidationError( + f"Trivy database repository {field} evidence is invalid" + ) + if not (started <= validated_at <= completed): + raise ReleaseValidationError("Trivy database was not validated during the recorded build") + + images = _list(receipt.get("images"), "receipt images") + roles = [image.get("role") if isinstance(image, dict) else None for image in images] + if len(roles) != len(set(roles)): + raise ReleaseValidationError("duplicate image role in build receipt") + if roles != list(REQUIRED_ROLES): + missing = sorted(set(REQUIRED_ROLES) - set(roles)) + extra = sorted(str(role) for role in set(roles) - set(REQUIRED_ROLES)) + raise ReleaseValidationError(f"image roles are incomplete or unordered: missing={missing}, extra={extra}") + + tracked_paths: dict[str, str] = {receipt_relative.casefold(): receipt_relative} + expected_files: set[str] = {receipt_relative} + manifest_images: list[dict[str, Any]] = [] + oci_manifest_digests: set[str] = set() + aggregate_vulnerability_counts = { + bucket: {severity: 0 for severity in policy["vulnerability_scan"]["thresholds"]} + for bucket in ("total", "exempted", "blocking") + } + applied_waiver_ids: set[str] = set() + + def track(record: dict[str, Any], label: str) -> None: + path = _record_path(record, label) + validate_relative_path(path, label=f"{label}.path") + folded = path.casefold() + if folded in tracked_paths: + raise ReleaseValidationError( + f"duplicate or case-colliding artifact path: {path} and {tracked_paths[folded]}" + ) + tracked_paths[folded] = path + expected_files.add(path) + + track(policy_record, "policy") + track(_mapping(candidate["source_manifest"], "source manifest"), "source manifest") + track(_mapping(candidate["source_archive"], "source archive"), "source archive") + track(database_record, "Trivy database metadata") + track(compose_env_record, "synthetic Compose gate environment") + track(compose_report_record, "production Compose gate report") + if compose_env_record["path"] != "validation/synthetic-compose-gate.env": + raise ReleaseValidationError("synthetic Compose gate environment path drift") + if compose_report_record["path"] != "validation/production-compose-gate.json": + raise ReleaseValidationError("production Compose gate report path drift") + + for index, (raw_image, expected) in enumerate(zip(images, REQUIRED_IMAGE_SPECS, strict=True), 1): + image = _mapping(raw_image, f"receipt image {index}") + if image.get("sequence") != index: + raise ReleaseValidationError(f"image sequence drift for {expected['role']}") + if image.get("role") != expected["role"] or image.get("image_name") != expected["name"]: + raise ReleaseValidationError(f"image identity drift for sequence {index}") + if image.get("environment_key") != expected["environment_key"]: + raise ReleaseValidationError(f"deployment environment key drift for {expected['role']}") + if image.get("dockerfile") != expected["dockerfile"]: + raise ReleaseValidationError(f"Dockerfile drift for {expected['role']}") + if image.get("base_image") != expected["base_image"]: + raise ReleaseValidationError(f"base-image transport/digest drift for {expected['role']}") + if image.get("platform") != TARGET["platform"]: + raise ReleaseValidationError(f"platform drift for {expected['role']}") + expected_ref = f"{expected['name']}:{candidate['commit']}" + if image.get("image_ref") != expected_ref: + raise ReleaseValidationError(f"image reference is not commit-bound for {expected['role']}") + + oci_record = _mapping(image.get("oci"), f"{expected['role']} OCI record") + docker_record = _mapping( + image.get("docker_archive"), f"{expected['role']} Docker archive record" + ) + sbom_record = _mapping(image.get("sbom"), f"{expected['role']} SBOM record") + scan_record = _mapping(image.get("scan"), f"{expected['role']} scan record") + for record, kind, required_prefix in ( + (oci_record, "OCI", "images/"), + (docker_record, "Docker archive", "images-docker/"), + (sbom_record, "SBOM", "sbom/"), + (scan_record, "scan", "scans/"), + ): + track(record, f"{expected['role']} {kind}") + if not _record_path(record, f"{expected['role']} {kind}").startswith(required_prefix): + raise ReleaseValidationError(f"{expected['role']} {kind} path is outside {required_prefix}") + + oci_path = verify_file_record(artifact_root, oci_record, label=f"{expected['role']} OCI") + oci_summary = inspect_oci_archive(oci_path) + _compare_summary(oci_record, oci_summary, label=f"{expected['role']} OCI") + if oci_summary["revision"] != candidate["commit"] or oci_summary["version"] != candidate["tag"]: + raise ReleaseValidationError(f"OCI candidate labels drift for {expected['role']}") + if ( + oci_summary["release_role"] != expected["role"] + or oci_summary["title"] != expected["name"] + ): + raise ReleaseValidationError(f"OCI role labels drift for {expected['role']}") + manifest_digest = oci_summary["manifest_digest"] + if manifest_digest in oci_manifest_digests: + raise ReleaseValidationError(f"duplicate OCI manifest digest across image roles: {manifest_digest}") + oci_manifest_digests.add(manifest_digest) + + docker_path = verify_file_record( + artifact_root, docker_record, label=f"{expected['role']} Docker archive" + ) + docker_summary = inspect_docker_archive( + docker_path, expected_repo_tag=expected_ref + ) + _compare_summary( + docker_record, docker_summary, label=f"{expected['role']} Docker archive" + ) + if docker_summary["config_digest"] != oci_summary["config_digest"]: + raise ReleaseValidationError( + f"OCI/Docker archive image identity drift for {expected['role']}" + ) + if ( + docker_summary["revision"] != candidate["commit"] + or docker_summary["version"] != candidate["tag"] + or docker_summary["release_role"] != expected["role"] + or docker_summary["title"] != expected["name"] + ): + raise ReleaseValidationError(f"Docker archive role labels drift for {expected['role']}") + + sbom_path = verify_file_record(artifact_root, sbom_record, label=f"{expected['role']} SBOM") + sbom_summary = validate_spdx(load_json_file(sbom_path, label=f"{expected['role']} SBOM"), policy) + _compare_summary(sbom_record, sbom_summary, label=f"{expected['role']} SBOM") + + scan_path = verify_file_record(artifact_root, scan_record, label=f"{expected['role']} scan") + scan_evaluated_at = parse_rfc3339( + scan_record.get("waiver_evaluated_at"), + label=f"{expected['role']} scan waiver_evaluated_at", + ) + if not (started <= scan_evaluated_at <= completed): + raise ReleaseValidationError( + f"{expected['role']} vulnerability waiver was not evaluated during the build" + ) + scan_summary = validate_trivy_scan( + load_json_file(scan_path, label=f"{expected['role']} Trivy scan"), + policy, + expected_config_digest=oci_summary["config_digest"], + expected_artifact_basename=docker_path.name, + role=expected["role"], + at=scan_evaluated_at, + ) + _compare_summary(scan_record, scan_summary, label=f"{expected['role']} scan") + if scan_record.get("scanner") != "trivy" or scan_record.get("scanner_version") != REQUIRED_TOOL_VERSIONS["trivy"]: + raise ReleaseValidationError(f"scanner identity drift for {expected['role']}") + if scan_record.get("passed") is not True: + raise ReleaseValidationError(f"scan is not passing for {expected['role']}") + for bucket in aggregate_vulnerability_counts: + for severity in aggregate_vulnerability_counts[bucket]: + aggregate_vulnerability_counts[bucket][severity] += scan_summary["counts"][ + bucket + ][severity] + applied_waiver_ids.update(scan_summary["waiver_ids"]) + manifest_images.append( + { + "role": expected["role"], + "image_name": expected["name"], + "environment_key": expected["environment_key"], + "image_ref": expected_ref, + "platform": TARGET["platform"], + "manifest_digest": manifest_digest, + "config_digest": oci_summary["config_digest"], + "registry_reference": f"{expected['name']}@{manifest_digest}", + "offline_image_id": oci_summary["config_digest"], + "oci": {key: oci_record[key] for key in ("path", "sha256", "size_bytes")}, + "docker_archive": { + key: docker_record[key] for key in ("path", "sha256", "size_bytes") + }, + "sbom": {key: sbom_record[key] for key in ("path", "sha256", "size_bytes")}, + "scan": {key: scan_record[key] for key in ("path", "sha256", "size_bytes")}, + } + ) + + if claimed_vulnerability_counts != aggregate_vulnerability_counts: + raise ReleaseValidationError("receipt aggregate vulnerability counts drift") + if claimed_waiver_ids != sorted(applied_waiver_ids): + raise ReleaseValidationError("receipt applied waiver ID inventory drift") + + if output.parent.resolve(strict=True) != artifact_root.resolve(strict=True): + raise ReleaseValidationError("installable manifest must be written directly in the artifact root") + _reject_unlisted_entries(artifact_root, expected_files, output) + + indexed_records = [ + { + "path": path, + "sha256": sha_record["sha256"], + "size_bytes": sha_record["size_bytes"], + } + for sha_record in ( + policy_record, + candidate["source_manifest"], + candidate["source_archive"], + database_record, + compose_env_record, + compose_report_record, + *( + artifact + for image in images + for artifact in ( + image["oci"], + image["docker_archive"], + image["sbom"], + image["scan"], + ) + ), + ) + for path in [sha_record["path"]] + ] + indexed_records.append(file_record(receipt_path, receipt_relative)) + indexed_records.sort(key=lambda item: item["path"].encode("utf-8")) + installable_without_digest = { + "schema_version": INSTALLABLE_MANIFEST_SCHEMA, + "candidate": { + "commit": candidate["commit"], + "tag": candidate["tag"], + "tag_object_oid": candidate["tag_object_oid"], + "tree": candidate["tree"], + "commit_tree": candidate["commit_tree"], + "source_manifest_payload_sha256": candidate["source_manifest_payload_sha256"], + "checkout": candidate_checkout, + }, + "target": TARGET, + "policy": { + "path": policy_record["path"], + "schema_version": policy["schema_version"], + "sha256": policy_record["sha256"], + "size_bytes": policy_record["size_bytes"], + "target": TARGET, + }, + "build_receipt": { + **file_record(receipt_path, receipt_relative), + "payload_sha256": receipt["receipt_payload_sha256"], + }, + "security_gate": { + "passed": True, + "fail_closed": True, + "thresholds": policy["vulnerability_scan"]["thresholds"], + "exemptions": policy["vulnerability_scan"]["exemptions"], + "vulnerability_counts": aggregate_vulnerability_counts, + "waiver_ids": sorted(applied_waiver_ids), + "trivy_database": { + **database_summary, + "repository_transport": repository_transport, + }, + }, + "execution_contract": { + "command_timeouts_seconds": COMMAND_TIMEOUTS_SECONDS, + "controlled_environment": CONTROLLED_SUBPROCESS_ENV, + "docker_host": LOCAL_DOCKER_HOST, + "pinned_executables": PINNED_EXECUTABLES, + "release_controls": release_controls, + }, + "compose_gate": { + "passed": True, + "synthetic_environment": True, + "production_secrets_used": False, + "execution_engine": "self_bound_release_runner_builtin_v1", + "validator": compose_validator, + "environment": { + key: compose_env_record[key] for key in ("path", "sha256", "size_bytes") + }, + "report": { + key: compose_report_record[key] + for key in ("path", "sha256", "size_bytes") + }, + "case_count": compose_report_summary["case_count"], + "compose_inputs": compose_report_summary["compose_inputs"], + }, + "images": manifest_images, + "docker_import_plan": { + "schema_version": "yuqei.docker-image-import-plan.v1", + "implicit_preloaded_images_allowed": False, + "images": [ + { + "sequence": index, + "role": image["role"], + "environment_key": image["environment_key"], + "archive": image["docker_archive"], + "command_argv": [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "image", + "load", + "--input", + image["docker_archive"]["path"], + ], + "expected_repo_tag": image["image_ref"], + "expected_loaded_image_id": image["config_digest"], + "expected_oci_manifest_digest": image["manifest_digest"], + "offline_compose_override": image["offline_image_id"], + "registry_reference_for_online_release": image["registry_reference"], + "repo_digests_expected_after_load": [], + } + for index, image in enumerate(manifest_images, 1) + ], + "consumer_requirement": ( + "An installer must verify docker load produced the exact config-based image ID, " + "then use that sha256 image ID as the offline Compose override. The registry " + "reference is online-only because docker load does not restore RepoDigests. " + "This assembler does not import images." + ), + }, + "files": indexed_records, + "claims_not_made": [ + "This manifest does not claim that an installer consumed its Docker import plan.", + "This manifest does not claim installation, deployment, migration, health, UAT, rollback, or public-release approval." + ], + } + installable = with_payload_digest(installable_without_digest, "manifest_payload_sha256") + if write_output: + _write_exclusive_manifest(output, installable) + return installable + + +def validate_installable_release_manifest( + *, + artifact_root: Path, + manifest_path: Path, + expected_receipt_sha256: str, + repo: Path, +) -> dict[str, Any]: + """Rebuild and byte-compare an existing manifest with the strict assembler.""" + + manifest_relative = _path_relative_to_root( + artifact_root, + manifest_path, + label="installable release manifest", + ) + if Path(manifest_relative).parent != Path("."): + raise ReleaseValidationError( + "installable release manifest must be directly in the artifact root" + ) + manifest_bytes = manifest_path.read_bytes() + manifest = load_json_file(manifest_path, label="installable release manifest") + if canonical_json_bytes(manifest) != manifest_bytes: + raise ReleaseValidationError( + "installable release manifest is not serialized as canonical JSON" + ) + build_receipt = _mapping(manifest.get("build_receipt"), "installable build_receipt") + if set(build_receipt) != {"path", "sha256", "size_bytes", "payload_sha256"}: + raise ReleaseValidationError("installable build_receipt fields are missing or unknown") + receipt_relative = _record_path(build_receipt, "installable build_receipt") + receipt_path = ensure_regular_file_under(artifact_root, receipt_relative) + expected = assemble_installable_release( + artifact_root=artifact_root, + receipt_path=receipt_path, + expected_receipt_sha256=expected_receipt_sha256, + repo=repo, + output=manifest_path, + write_output=False, + ) + if manifest != expected: + raise ReleaseValidationError( + "installable release manifest differs from the strict reconstructed manifest" + ) + return manifest + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-root", type=Path, required=True) + parser.add_argument("--receipt", type=Path, help="defaults to ARTIFACT_ROOT/build-receipt.json") + parser.add_argument( + "--expected-build-receipt-sha256", + required=True, + help="Independently supplied raw SHA-256 of the canonical build receipt.", + ) + parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Git repository containing the exact tag") + parser.add_argument("--output", type=Path, help="defaults to ARTIFACT_ROOT/installable-release-manifest.json") + args = parser.parse_args(argv) + root = args.artifact_root.resolve() + receipt = args.receipt.resolve() if args.receipt else root / "build-receipt.json" + output = args.output.resolve() if args.output else root / "installable-release-manifest.json" + try: + manifest = assemble_installable_release( + artifact_root=root, + receipt_path=receipt, + expected_receipt_sha256=args.expected_build_receipt_sha256, + repo=args.repo.resolve(), + output=output, + ) + print( + json.dumps( + { + "status": "passed", + "output": str(output), + "commit": manifest["candidate"]["commit"], + "images": len(manifest["images"]), + }, + sort_keys=True, + ) + ) + return 0 + except (OSError, ReleaseValidationError) as error: + print(f"installable_release_error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/scripts/build-release-candidate-manifest.py b/yuqei-infra/scripts/build-release-candidate-manifest.py index 6e02e67c..3feb5df8 100644 --- a/yuqei-infra/scripts/build-release-candidate-manifest.py +++ b/yuqei-infra/scripts/build-release-candidate-manifest.py @@ -7,7 +7,9 @@ import argparse import ast import hashlib import json +import os import re +import shutil import subprocess import sys import tomllib @@ -37,6 +39,33 @@ REQUIREMENT_LOCK_PATHS = ( "yuqei-infra/requirements/contract-api-linux-x86_64-py312.txt", "yuqei-infra/requirements/ai-runtime-linux-x86_64-py312.txt", ) +_WINDOWS_GIT_EXECUTABLE = ( + None + if sys.platform.startswith("linux") + else ( + str(Path(discovered).resolve()) + if (discovered := shutil.which("git")) is not None + else None + ) +) +RELEASE_CONTROL_PATHS = ( + "yuqei-infra/scripts/build-release-candidate-manifest.py", + "yuqei-infra/scripts/build-release-worktree-ownership.py", + "yuqei-infra/compose/docker-compose.production.offline.yml", + "yuqei-infra/deploy/preflight-production.py", + "yuqei-infra/deploy/verify-production-compose.py", + "yuqei-infra/deploy/release-package.py", + "yuqei-infra/release/linux-release-security-policy.v1.json", + "yuqei-infra/scripts/linux_release_common.py", + "yuqei-infra/scripts/run-linux-release-build.py", + "yuqei-infra/scripts/build-installable-release.py", + "yuqei-infra/tests/test_production_compose_preflight.py", + "yuqei-infra/tests/test_production_compose_merge_gate.py", + "yuqei-infra/tests/test_release_package_executor.py", + "yuqei-infra/tests/test_linux_release_build_tools.py", + "yuqei-infra/tests/test_installable_release_assembler_hardening.py", + "yuqei-infra/tests/test_release_candidate_manifest_tools.py", +) CONTRACT_LOCK_PATH = REQUIREMENT_LOCK_PATHS[0] CONTRACT_DOCKERFILE_PATH = "yuqei-infra/docker/contract-api.Dockerfile" REQUIRED_PATHS = ( @@ -47,6 +76,7 @@ REQUIRED_PATHS = ( *OPENAPI_PATHS, *PROMPT_DEFINITION_PATHS, *REQUIREMENT_LOCK_PATHS, + *RELEASE_CONTROL_PATHS, BASELINE_PATH, "yuqei-ai-sdk-python/src/yuqei_sdk/__init__.py", "yuqei-ai-sdk-python/src/yuqei_sdk/ai_platform.py", @@ -113,11 +143,49 @@ class TreeEntry: path: str +def _controlled_git_environment() -> dict[str, str]: + if sys.platform.startswith("linux"): + environment = { + "HOME": "/var/empty", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PATH": "/usr/bin:/bin", + } + else: + environment = { + key: os.environ[key] + for key in ("SYSTEMROOT", "WINDIR", "TEMP", "TMP") + if key in os.environ + } + if not _WINDOWS_GIT_EXECUTABLE: + raise ManifestError("Git executable is unavailable") + environment["PATH"] = str(Path(_WINDOWS_GIT_EXECUTABLE).parent) + environment.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"}) + environment.update( + { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_TERMINAL_PROMPT": "0", + } + ) + return environment + + +def _git_command(repo: Path, *args: str) -> list[str]: + git_path = "/usr/bin/git" if sys.platform.startswith("linux") else _WINDOWS_GIT_EXECUTABLE + if not git_path: + raise ManifestError("Git executable is unavailable") + return [str(git_path), "--no-replace-objects", "-C", str(repo), *args] + + def _git(repo: Path, *args: str, binary: bool = False) -> bytes | str: completed = subprocess.run( - ["git", "-C", str(repo), *args], + _git_command(repo, *args), check=False, capture_output=True, + env=_controlled_git_environment(), + timeout=30, ) if completed.returncode: message = completed.stderr.decode("utf-8", "replace").strip() @@ -136,6 +204,11 @@ def _is_within(path: Path, parent: Path) -> bool: def validate_candidate_identity(repo: Path, commit: str, output: Path) -> str: if not FULL_COMMIT_RE.fullmatch(commit): raise ManifestError("--commit must be a full hexadecimal commit object id; names such as HEAD are refused") + replacement_refs = str( + _git(repo, "for-each-ref", "--format=%(refname)", "refs/replace/") + ) + if replacement_refs: + raise ManifestError("repository contains forbidden refs/replace entries") resolved = str(_git(repo, "rev-parse", "--verify", f"{commit}^{{commit}}")) if resolved != commit: raise ManifestError(f"commit object id did not resolve exactly: {commit} -> {resolved}") @@ -174,10 +247,11 @@ def list_commit_tree(repo: Path, commit: str) -> list[TreeEntry]: def read_commit_blobs(repo: Path, entries: Iterable[TreeEntry]) -> dict[str, bytes]: process = subprocess.Popen( - ["git", "-C", str(repo), "cat-file", "--batch"], + _git_command(repo, "cat-file", "--batch"), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=_controlled_git_environment(), ) assert process.stdin is not None and process.stdout is not None and process.stderr is not None blobs: dict[str, bytes] = {} @@ -440,6 +514,9 @@ def configuration_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]: name.endswith("Dockerfile") or path.startswith("yuqei-infra/compose/") or path.startswith("yuqei-infra/nginx/") + or path.startswith("yuqei-infra/deploy/") + or path.startswith("yuqei-infra/release/") + or path in RELEASE_CONTROL_PATHS or name in {"pyproject.toml", "package.json", "package-lock.json", "alembic.ini", "tsconfig.base.json"} or path in REQUIREMENT_LOCK_PATHS ): @@ -455,7 +532,20 @@ def runtime_graph_summary(blobs: dict[str, bytes]) -> list[dict[str, Any]]: ("web_bff_design_system", ("yuqei-contract-platform/apps/contract-web/src/", "yuqei-contract-platform/packages/design-system/src/")), ("openapi", OPENAPI_PATHS), ("migrations", ("yuqei-contract-platform/services/contract-api/migrations/", "yuqei-ai-platform/services/ai-platform-api/migrations/")), - ("deployment", ("yuqei-infra/docker/", "yuqei-infra/compose/", "yuqei-infra/nginx/")), + ( + "deployment_and_release_controls", + ( + "yuqei-infra/docker/", + "yuqei-infra/compose/", + "yuqei-infra/nginx/", + "yuqei-infra/deploy/", + "yuqei-infra/release/", + "yuqei-infra/scripts/linux_release_common.py", + "yuqei-infra/scripts/run-linux-release-build.py", + "yuqei-infra/scripts/build-installable-release.py", + *RELEASE_CONTROL_PATHS, + ), + ), ) result = [] for name, prefixes in components: diff --git a/yuqei-infra/scripts/build-release-worktree-ownership.py b/yuqei-infra/scripts/build-release-worktree-ownership.py index f7247213..5d8d2ec9 100644 --- a/yuqei-infra/scripts/build-release-worktree-ownership.py +++ b/yuqei-infra/scripts/build-release-worktree-ownership.py @@ -78,6 +78,14 @@ RUNTIME_COMPONENTS = { "yuqei-infra/compose/", "yuqei-infra/nginx/", "yuqei-infra/requirements/", + "yuqei-infra/deploy/", + "yuqei-infra/release/", + "yuqei-infra/scripts/linux_release_common.py", + "yuqei-infra/scripts/run-linux-release-build.py", + "yuqei-infra/scripts/build-installable-release.py", + "yuqei-infra/scripts/build-release-candidate-manifest.py", + "yuqei-infra/scripts/build-release-worktree-ownership.py", + "yuqei-infra/tests/test_installable_release_assembler_hardening.py", ), } REQUIRED_WORKTREE_ANCHORS = ( @@ -104,7 +112,23 @@ REQUIRED_WORKTREE_ANCHORS = ( "yuqei-infra/docker/contract-web.Dockerfile", "yuqei-infra/compose/docker-compose.yml", "yuqei-infra/compose/docker-compose.production.yml", + "yuqei-infra/compose/docker-compose.production.offline.yml", "yuqei-infra/nginx/templates/default.conf.template", + "yuqei-infra/scripts/build-release-candidate-manifest.py", + "yuqei-infra/scripts/build-release-worktree-ownership.py", + "yuqei-infra/deploy/preflight-production.py", + "yuqei-infra/deploy/verify-production-compose.py", + "yuqei-infra/deploy/release-package.py", + "yuqei-infra/release/linux-release-security-policy.v1.json", + "yuqei-infra/scripts/linux_release_common.py", + "yuqei-infra/scripts/run-linux-release-build.py", + "yuqei-infra/scripts/build-installable-release.py", + "yuqei-infra/tests/test_production_compose_preflight.py", + "yuqei-infra/tests/test_production_compose_merge_gate.py", + "yuqei-infra/tests/test_release_package_executor.py", + "yuqei-infra/tests/test_linux_release_build_tools.py", + "yuqei-infra/tests/test_installable_release_assembler_hardening.py", + "yuqei-infra/tests/test_release_candidate_manifest_tools.py", ) @@ -354,7 +378,20 @@ def closure_audit(repo: Path, entries: list[dict[str, Any]]) -> dict[str, Any]: config_paths = [ path for path in REQUIRED_WORKTREE_ANCHORS - if path.startswith(("yuqei-infra/docker/", "yuqei-infra/compose/", "yuqei-infra/nginx/")) + if path.startswith( + ( + "yuqei-infra/docker/", + "yuqei-infra/compose/", + "yuqei-infra/nginx/", + "yuqei-infra/deploy/", + "yuqei-infra/release/", + ) + ) + or path in { + "yuqei-infra/scripts/linux_release_common.py", + "yuqei-infra/scripts/run-linux-release-build.py", + "yuqei-infra/scripts/build-installable-release.py", + } ] ownership_blockers = [ item["path"] diff --git a/yuqei-infra/scripts/linux_release_common.py b/yuqei-infra/scripts/linux_release_common.py new file mode 100644 index 00000000..94d6ed9f --- /dev/null +++ b/yuqei-infra/scripts/linux_release_common.py @@ -0,0 +1,1926 @@ +"""Strict, dependency-free validation helpers for the Linux release pipeline. + +The release scripts deliberately keep this module in the standard library so a +clean builder can validate all release inputs before it installs or runs any +third-party tooling. +""" + +from __future__ import annotations + +import hashlib +import gzip +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tarfile +from datetime import datetime, timedelta, timezone +from pathlib import Path, PurePosixPath +from typing import Any, BinaryIO, Iterable + + +POLICY_SCHEMA = "yuqei.linux-release-security-policy.v1" +SOURCE_MANIFEST_SCHEMA = "yuqei.release-source-manifest.v1" +BUILD_RECEIPT_SCHEMA = "yuqei.linux-release-build-receipt.v1" +INSTALLABLE_MANIFEST_SCHEMA = "yuqei.installable-release-manifest.v1" +TARGET = {"os": "linux", "architecture": "amd64", "platform": "linux/amd64"} +BUILDER_POLICY = { + "allowed_drivers": ["docker-container"], + "maximum_nodes": 1, + "required_buildkit_version": "0.13.2", + "required_driver_image": ( + "docker.m.daocloud.io/moby/buildkit@" + "sha256:9194b5ec1be368f41c516df7f93f7f540630ea06136056b2ffebb62226ed4ad6" + ), + "required_driver_image_digest": ( + "sha256:9194b5ec1be368f41c516df7f93f7f540630ea06136056b2ffebb62226ed4ad6" + ), + "required_exporters": ["oci", "docker"], + "required_local_driver_image_id": ( + "sha256:1a21071780b069d68bd7ef277675f1c5273fee488cec145494cf072777260797" + ), + "required_platform": "linux/amd64", +} +COMPOSE_GATE_POLICY = { + "case_count": 8, + "compose_inputs": [ + "yuqei-infra/compose/docker-compose.yml", + "yuqei-infra/compose/docker-compose.production.yml", + "yuqei-infra/compose/docker-compose.production.offline.yml", + ], + "production_secrets_forbidden": True, + "schema_version": "yuqei.production-compose-merge-gate.v1", + "synthetic_environment_required": True, + "validator": "yuqei-infra/deploy/verify-production-compose.py", +} +PINNED_EXECUTABLES = { + "docker": "/usr/bin/docker", + "git": "/usr/bin/git", + "syft": "/usr/bin/syft", + "trivy": "/usr/bin/trivy", +} +LOCAL_DOCKER_HOST = "unix:///var/run/docker.sock" +CONTROLLED_SUBPROCESS_ENV = { + "BUILDX_CONFIG": "/root/.docker/buildx", + "DOCKER_CONFIG": "/var/empty/docker", + "HOME": "/var/empty", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PATH": "/usr/bin:/bin", +} +COMMAND_TIMEOUTS_SECONDS = { + "base_image_inspect": 120, + "builder_inspect": 120, + "compose_gate": 600, + "container_inspect": 30, + "git_validation": 30, + "image_build": 3600, + "sbom_generation": 600, + "tool_version": 30, + "trivy_db_download": 600, + "trivy_db_inspect": 120, + "vulnerability_scan": 900, +} +TRIVY_DB_REPOSITORY_TRANSPORT = { + "allowed_media_types": ["application/vnd.oci.image.manifest.v1+json"], + "reference": "docker.m.daocloud.io/aquasec/trivy-db:2", + "schema_version": 2, + "transport": "domestic_registry_mirror", +} +GIT_VALIDATION_ENV = { + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_TERMINAL_PROMPT": "0", +} +# Windows supports read-only plan/assembly validation, while real release +# execution is Linux-only. Resolve Git once while this self-bound module is +# imported; a caller cannot redirect later validation by mutating ambient PATH. +_WINDOWS_GIT_EXECUTABLE = ( + None + if sys.platform.startswith("linux") + else ( + str(Path(discovered).resolve()) + if (discovered := shutil.which("git")) is not None + else None + ) +) +VULNERABILITY_WAIVERS = [ + { + "id": "RC04.4-DEBIAN13-PYTHON-UNFIXED-HIGH-001", + "expires_at": "2026-10-20T00:00:00Z", + "scope": { + "roles": ["contract-api", "ai-platform-api", "ai-worker"], + "class": "os-pkgs", + "type": "debian", + "os_family": "debian", + "severity": "HIGH", + "fixed_version_must_be_empty": True, + }, + "findings": [ + { + "vulnerability_id": "CVE-2025-69720", + "package_names": ["libncursesw6", "libtinfo6", "ncurses-base", "ncurses-bin"], + "installed_version": "6.5+20250216-2", + }, + { + "vulnerability_id": "CVE-2026-16742", + "package_names": ["libsystemd0", "libudev1"], + "installed_version": "257.13-1~deb13u1", + }, + { + "vulnerability_id": "CVE-2026-54369", + "package_names": ["libacl1"], + "installed_version": "2.3.2-2+b1", + }, + { + "vulnerability_id": "CVE-2026-76642", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux", + ], + "installed_version": "1:2.41.5-0+deb13u1", + }, + { + "vulnerability_id": "CVE-2026-78408", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux", + ], + "installed_version": "1:2.41.5-0+deb13u1", + }, + { + "vulnerability_id": "CVE-2026-78409", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux", + ], + "installed_version": "1:2.41.5-0+deb13u1", + }, + { + "vulnerability_id": "CVE-2026-78410", + "package_names": [ + "bsdutils", + "libblkid1", + "liblastlog2-2", + "libmount1", + "libsmartcols1", + "libuuid1", + "login", + "mount", + "util-linux", + ], + "installed_version": "1:2.41.5-0+deb13u1", + }, + { + "vulnerability_id": "CVE-2026-9538", + "package_names": ["perl-base"], + "installed_version": "5.40.1-6+deb13u1", + }, + ], + } +] +PYTHON_BASE = { + "build_arg": "PYTHON_BASE_IMAGE", + "content_digest": "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", + "source_reference": ( + "python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b" + ), + "transport": "domestic_registry_mirror", + "transport_reference": ( + "docker.m.daocloud.io/library/python:3.12-slim@" + "sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b" + ), +} +NODE_BASE = { + "build_arg": "NODE_BASE_IMAGE", + "content_digest": "sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50", + "source_reference": ( + "node:22-alpine@sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50" + ), + "transport": "domestic_registry_mirror", + "transport_reference": ( + "docker.m.daocloud.io/library/node:22-alpine@" + "sha256:b64da1de5a51067ab8e75f0bc8dbd0905d8894baa22261f439a4572f41291e50" + ), +} +REQUIRED_TOOL_VERSIONS = { + "docker_engine": "26.1.3", + "docker_cli": "26.1.3", + "buildx": "0.14.0", + "compose": "2.27.0", + "syft": "1.52.0", + "trivy": "0.74.0", +} +REQUIRED_IMAGE_SPECS = ( + { + "role": "contract-web", + "base_image": NODE_BASE, + "name": "yuqei/contract-web", + "environment_key": "CONTRACT_WEB_IMAGE", + "dockerfile": "yuqei-infra/docker/contract-web.Dockerfile", + }, + { + "role": "contract-api", + "base_image": PYTHON_BASE, + "name": "yuqei/contract-api", + "environment_key": "CONTRACT_API_IMAGE", + "dockerfile": "yuqei-infra/docker/contract-api.Dockerfile", + }, + { + "role": "ai-platform-api", + "base_image": PYTHON_BASE, + "name": "yuqei/ai-platform-api", + "environment_key": "AI_PLATFORM_API_IMAGE", + "dockerfile": "yuqei-infra/docker/ai-platform-api.Dockerfile", + }, + { + "role": "ai-worker", + "base_image": PYTHON_BASE, + "name": "yuqei/ai-worker", + "environment_key": "AI_WORKER_IMAGE", + "dockerfile": "yuqei-infra/docker/worker.Dockerfile", + }, +) +REQUIRED_ROLES = tuple(item["role"] for item in REQUIRED_IMAGE_SPECS) +SHA256_RE = re.compile(r"[0-9a-f]{64}") +FULL_COMMIT_RE = re.compile(r"[0-9a-f]{40}") +GIT_OBJECT_RE = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})") +TAG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}") +RFC3339_RE = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}" + r"(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})" +) + + +class ReleaseValidationError(RuntimeError): + """An input or generated artifact is unsafe, ambiguous, or inconsistent.""" + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ReleaseValidationError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_json_bytes(data: bytes, *, label: str) -> Any: + try: + text = data.decode("utf-8", "strict") + except UnicodeDecodeError as error: + raise ReleaseValidationError(f"{label} is not UTF-8: {error}") from error + try: + return json.loads( + text, + object_pairs_hook=_reject_duplicate_keys, + parse_constant=lambda value: (_ for _ in ()).throw( + ReleaseValidationError(f"non-finite JSON value: {value}") + ), + ) + except (json.JSONDecodeError, ReleaseValidationError) as error: + raise ReleaseValidationError(f"{label} is not valid unambiguous JSON: {error}") from error + + +def load_json_file(path: Path, *, label: str) -> Any: + try: + data = path.read_bytes() + except OSError as error: + raise ReleaseValidationError(f"cannot read {label} {path}: {error}") from error + return load_json_bytes(data, label=label) + + +def canonical_json_bytes(payload: Any) -> bytes: + return ( + json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + while chunk := handle.read(1024 * 1024): + digest.update(chunk) + except OSError as error: + raise ReleaseValidationError(f"cannot hash {path}: {error}") from error + return digest.hexdigest() + + +def controlled_subprocess_env(extra: dict[str, str] | None = None) -> dict[str, str]: + """Return the allowlisted child environment used by release commands.""" + + if sys.platform.startswith("linux"): + result = dict(CONTROLLED_SUBPROCESS_ENV) + else: + # Plan/assembly validation remains usable on Windows, but real builds + # are Linux-only. Keep only OS bootstrap variables and a fixed Git + # directory; never forward Docker routing/configuration variables. + result = { + key: os.environ[key] + for key in ("SYSTEMROOT", "WINDIR", "TEMP", "TMP") + if key in os.environ + } + if _WINDOWS_GIT_EXECUTABLE: + result["PATH"] = str(Path(_WINDOWS_GIT_EXECUTABLE).parent) + result.update({"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"}) + if extra: + result.update(extra) + return result + + +def release_git_executable() -> str: + if sys.platform.startswith("linux"): + return PINNED_EXECUTABLES["git"] + if not _WINDOWS_GIT_EXECUTABLE: + raise ReleaseValidationError("Git executable is unavailable for plan validation") + return _WINDOWS_GIT_EXECUTABLE + + +def validate_pinned_executables() -> dict[str, dict[str, Any]]: + """Validate root ownership/mode and hash every pinned Linux executable.""" + + if not sys.platform.startswith("linux"): + raise ReleaseValidationError("pinned release executables can be validated only on Linux") + result: dict[str, dict[str, Any]] = {} + for name, raw_path in PINNED_EXECUTABLES.items(): + path = Path(raw_path) + if path.is_symlink() or not path.is_file(): + raise ReleaseValidationError(f"pinned {name} executable is missing or a symlink: {path}") + metadata = path.stat() + mode = stat.S_IMODE(metadata.st_mode) + if metadata.st_uid != 0 or mode != 0o755: + raise ReleaseValidationError( + f"pinned {name} executable must be root-owned mode 0755: {path}" + ) + result[name] = { + "path": raw_path, + "sha256": sha256_file(path), + "size_bytes": metadata.st_size, + "owner_uid": metadata.st_uid, + "mode": "0755", + } + return result + + +def validate_buildx_config_root(builder_name: str) -> dict[str, Any]: + """Validate the one fixed Buildx state tree without trusting ambient Docker config.""" + + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", builder_name): + raise ReleaseValidationError("Buildx builder name is missing or unsafe") + root = Path(CONTROLLED_SUBPROCESS_ENV["BUILDX_CONFIG"]) + required_directories = [Path("/root"), Path("/root/.docker"), root] + for path in required_directories: + if path.is_symlink() or not path.is_dir(): + raise ReleaseValidationError(f"fixed Buildx config component is missing or unsafe: {path}") + metadata = path.stat() + if metadata.st_uid != 0 or stat.S_IMODE(metadata.st_mode) & 0o022: + raise ReleaseValidationError( + f"fixed Buildx config component is not root-owned/non-writable: {path}" + ) + instance = root / "instances" / builder_name + if instance.is_symlink() or not instance.is_file(): + raise ReleaseValidationError( + f"fixed Buildx config has no regular selected-builder instance: {instance}" + ) + inventory: list[str] = [] + for path in sorted(root.rglob("*"), key=lambda item: item.as_posix().encode("utf-8")): + relative = path.relative_to(root).as_posix() + if path.is_symlink(): + raise ReleaseValidationError(f"fixed Buildx config contains a symlink: {relative}") + metadata = path.stat() + mode = stat.S_IMODE(metadata.st_mode) + if metadata.st_uid != 0 or mode & 0o022: + raise ReleaseValidationError( + f"fixed Buildx config entry is not root-owned/non-writable: {relative}" + ) + if path.is_dir(): + kind = "directory" + elif path.is_file(): + kind = "file" + else: + raise ReleaseValidationError( + f"fixed Buildx config contains a special entry: {relative}" + ) + inventory.append(f"{relative}\0{kind}\0{metadata.st_uid}\0{mode:04o}") + return { + "path": root.as_posix(), + "selected_builder_instance": instance.relative_to(root).as_posix(), + "entry_count": len(inventory), + "metadata_sha256": sha256_bytes("\n".join(inventory).encode("utf-8")), + "root_owned": True, + "group_or_other_writable": False, + "symlinks_allowed": False, + } + + +def file_record(path: Path, relative_path: str, **extra: Any) -> dict[str, Any]: + validate_relative_path(relative_path, label="artifact path") + record = { + "path": relative_path, + "sha256": sha256_file(path), + "size_bytes": path.stat().st_size, + } + record.update(extra) + return record + + +def validate_relative_path(value: str, *, label: str) -> PurePosixPath: + if not isinstance(value, str) or not value: + raise ReleaseValidationError(f"{label} must be a non-empty POSIX relative path") + if "\\" in value or "\x00" in value or ":" in value: + raise ReleaseValidationError(f"{label} contains a forbidden character: {value!r}") + path = PurePosixPath(value) + if path.is_absolute() or value.startswith("/"): + raise ReleaseValidationError(f"{label} must be relative: {value!r}") + if any(part in {"", ".", ".."} for part in path.parts): + raise ReleaseValidationError(f"{label} contains traversal or ambiguous components: {value!r}") + if path.as_posix() != value: + raise ReleaseValidationError(f"{label} is not canonical POSIX form: {value!r}") + return path + + +def _require_mapping(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ReleaseValidationError(f"{label} must be a JSON object") + return value + + +def _require_list(value: Any, label: str) -> list[Any]: + if not isinstance(value, list): + raise ReleaseValidationError(f"{label} must be a JSON array") + return value + + +def _require_exact_keys(value: dict[str, Any], required: set[str], label: str) -> None: + actual = set(value) + if actual != required: + raise ReleaseValidationError( + f"{label} keys differ: missing={sorted(required - actual)}, unknown={sorted(actual - required)}" + ) + + +def validate_policy(payload: Any) -> dict[str, Any]: + policy = _require_mapping(payload, "release policy") + _require_exact_keys( + policy, + { + "schema_version", + "target", + "builder", + "compose_gate", + "execution", + "toolchain", + "images", + "sbom", + "vulnerability_scan", + "unsupported_tools", + }, + "release policy", + ) + if policy["schema_version"] != POLICY_SCHEMA: + raise ReleaseValidationError(f"unsupported release policy schema: {policy['schema_version']!r}") + if policy["target"] != TARGET: + raise ReleaseValidationError(f"release target must be exactly {TARGET}") + if policy["builder"] != BUILDER_POLICY: + raise ReleaseValidationError( + "builder policy must require one linux/amd64 docker-container node with OCI and Docker exporters" + ) + if policy["compose_gate"] != COMPOSE_GATE_POLICY: + raise ReleaseValidationError( + "Compose gate policy must require the eight synthetic online/offline cases" + ) + execution = _require_mapping(policy["execution"], "execution") + if execution != { + "command_timeouts_seconds": COMMAND_TIMEOUTS_SECONDS, + "controlled_environment": CONTROLLED_SUBPROCESS_ENV, + "docker_host": LOCAL_DOCKER_HOST, + "pinned_executables": PINNED_EXECUTABLES, + }: + raise ReleaseValidationError( + "execution policy must pin the complete external-command timeout contract" + ) + + toolchain = _require_mapping(policy["toolchain"], "toolchain") + if set(toolchain) != set(REQUIRED_TOOL_VERSIONS): + raise ReleaseValidationError("toolchain entries do not match the required pinned tool set") + for tool, required_version in REQUIRED_TOOL_VERSIONS.items(): + entry = _require_mapping(toolchain[tool], f"toolchain.{tool}") + _require_exact_keys(entry, {"allowed_versions"}, f"toolchain.{tool}") + if entry["allowed_versions"] != [required_version]: + raise ReleaseValidationError( + f"toolchain.{tool} must allow only {required_version}, got {entry['allowed_versions']!r}" + ) + + images = _require_list(policy["images"], "images") + if images != list(REQUIRED_IMAGE_SPECS): + raise ReleaseValidationError( + "image roles, order, names, and Dockerfiles must match the four-image release contract" + ) + for index, image in enumerate(images): + _require_exact_keys( + _require_mapping(image, f"images[{index}]"), + {"role", "name", "environment_key", "dockerfile", "base_image"}, + f"images[{index}]", + ) + validate_relative_path(image["dockerfile"], label=f"images[{index}].dockerfile") + + sbom = _require_mapping(policy["sbom"], "sbom") + if sbom != {"generator": "syft", "format": "spdx-json", "spdx_version": "SPDX-2.3"}: + raise ReleaseValidationError("SBOM policy must require Syft SPDX-2.3 JSON") + + scan = _require_mapping(policy["vulnerability_scan"], "vulnerability_scan") + _require_exact_keys( + scan, + { + "database", + "exemptions", + "fail_closed_on", + "format", + "ignore_unfixed", + "scanner", + "scanners", + "thresholds", + }, + "vulnerability_scan", + ) + if scan["scanner"] != "trivy" or scan["format"] != "json" or scan["scanners"] != ["vuln"]: + raise ReleaseValidationError("vulnerability scanner must be Trivy JSON in vuln-only mode") + if scan["ignore_unfixed"] is not False: + raise ReleaseValidationError("unfixed vulnerabilities must not be ignored") + if scan["thresholds"] != {"CRITICAL": 0, "HIGH": 0}: + raise ReleaseValidationError("vulnerability thresholds must be CRITICAL=0 and HIGH=0") + if scan["exemptions"] != VULNERABILITY_WAIVERS: + raise ReleaseValidationError( + "release vulnerability waivers must exactly match the time-limited package allowlist" + ) + fail_closed = _require_list(scan["fail_closed_on"], "vulnerability_scan.fail_closed_on") + mandatory_failures = { + "database_download_failure", + "database_metadata_missing", + "database_expired", + "database_too_old", + "scanner_execution_failure", + "scan_output_missing", + "json_parse_failure", + "schema_validation_failure", + "waiver_expired", + "waiver_scope_mismatch", + "waiver_finding_mismatch", + } + if set(fail_closed) != mandatory_failures or len(fail_closed) != len(mandatory_failures): + raise ReleaseValidationError("fail-closed conditions are incomplete or duplicated") + database = _require_mapping(scan["database"], "vulnerability_scan.database") + _require_exact_keys( + database, + { + "fail_closed", + "maximum_age_hours", + "repository_transport", + "require_next_update_after_scan", + }, + "vulnerability_scan.database", + ) + if database != { + "fail_closed": True, + "maximum_age_hours": 24, + "repository_transport": TRIVY_DB_REPOSITORY_TRANSPORT, + "require_next_update_after_scan": True, + }: + raise ReleaseValidationError("Trivy database must fail closed at a 24-hour maximum age") + if policy["unsupported_tools"] != { + "skopeo": "not_required_and_must_not_be_used_by_this_pipeline" + }: + raise ReleaseValidationError("Skopeo must remain explicitly outside this pipeline") + return policy + + +def load_policy(path: Path) -> dict[str, Any]: + return validate_policy(load_json_file(path, label="release policy")) + + +def synthetic_compose_gate_env_bytes() -> bytes: + """Return the deterministic, non-secret environment for structural Compose validation.""" + + image_values = { + "CONTRACT_API_IMAGE": ("contract-api", "1"), + "CONTRACT_WEB_IMAGE": ("contract-web", "2"), + "AI_PLATFORM_API_IMAGE": ("ai-platform-api", "3"), + "AI_WORKER_IMAGE": ("ai-worker", "4"), + "POSTGRES_IMAGE": ("postgres", "5"), + "POSTGRES_CLIENT_IMAGE": ("postgres-client", "6"), + "REDIS_IMAGE": ("redis", "7"), + "MINIO_IMAGE": ("minio", "8"), + "MINIO_MC_IMAGE": ("minio-mc", "9"), + "NGINX_IMAGE": ("nginx", "a"), + "BACKUP_UTILITY_IMAGE": ("backup-utility", "b"), + } + values = { + key: f"synthetic.invalid/yuqei/{name}@sha256:{digit * 64}" + for key, (name, digit) in image_values.items() + } + values.update( + { + "AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256": "c" * 64, + "AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_FILE_HOST": ( + "/synthetic/provider-acceptance/receipt.json" + ), + "AI_PLATFORM_PROVIDER_ACCEPTANCE_TRUSTED_KEYS_FILE_HOST": ( + "/synthetic/provider-acceptance/trusted-keys.json" + ), + "AI_PLATFORM_RELEASE_IMAGE_DIGEST": "sha256:" + "d" * 64, + "AI_PROVIDER_API_KEY_FILE": "/synthetic/not-a-secret/provider-api-key", + "BACKEND_SERVER_NAME": "synthetic.invalid", + "BACKEND_TLS_CERT_DIR": "/synthetic/tls", + "BACKUP_HOST_DIR": "/synthetic/backups", + "COMPOSE_PROJECT_NAME": "yuqei-rc04-structural-gate", + "CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST": "sha256:" + "e" * 64, + "MINIO_ROOT_PASSWORD": "synthetic-not-a-production-secret", + "MINIO_ROOT_USER": "synthetic-not-a-production-user", + "PUBLIC_POLICY_CANDIDATE_DIR_HOST": "/synthetic/public-policy/candidate", + "PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST": ( + "/synthetic/public-policy/legal-approval.json" + ), + "PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST": ( + "/synthetic/public-policy/trusted-keyring.json" + ), + "RELEASE_ID": "synthetic-rc04-structure-only", + "YUQEI_COMPOSE_GATE_SYNTHETIC": "true", + } + ) + header = ( + "# SYNTHETIC STRUCTURAL VALIDATION ONLY.\n" + "# Contains no production credentials and must never be used for deployment.\n" + ) + body = "".join(f"{key}={values[key]}\n" for key in sorted(values)) + return (header + body).encode("utf-8") + + +def validate_compose_gate_report( + payload: Any, + policy: dict[str, Any], + *, + expected_env_sha256: str, + source_file_sha256: dict[str, str], +) -> dict[str, Any]: + report = _require_mapping(payload, "production Compose gate report") + _require_exact_keys( + report, + { + "schema_version", + "status", + "env_file_sha256", + "compose_inputs", + "application_service_count", + "production_service_count", + "cases", + }, + "production Compose gate report", + ) + gate_policy = policy["compose_gate"] + if report["schema_version"] != gate_policy["schema_version"] or report["status"] != "passed": + raise ReleaseValidationError("production Compose gate did not report a supported pass") + if report["env_file_sha256"] != expected_env_sha256: + raise ReleaseValidationError("production Compose gate environment digest drift") + compose_inputs = _require_list(report["compose_inputs"], "Compose gate inputs") + if len(compose_inputs) != len(gate_policy["compose_inputs"]): + raise ReleaseValidationError("production Compose gate input count drift") + validated_inputs: list[dict[str, str]] = [] + for index, (raw_input, expected_path) in enumerate( + zip(compose_inputs, gate_policy["compose_inputs"], strict=True) + ): + item = _require_mapping(raw_input, f"Compose gate input {index}") + _require_exact_keys(item, {"path", "sha256"}, f"Compose gate input {index}") + expected_digest = source_file_sha256.get(expected_path) + if item.get("path") != expected_path or item.get("sha256") != expected_digest: + raise ReleaseValidationError(f"production Compose gate input drift: {expected_path}") + if not isinstance(expected_digest, str) or not SHA256_RE.fullmatch(expected_digest): + raise ReleaseValidationError(f"source manifest lacks Compose input: {expected_path}") + validated_inputs.append({"path": expected_path, "sha256": expected_digest}) + if report["application_service_count"] != 9 or report["production_service_count"] != 16: + raise ReleaseValidationError("production Compose gate service inventory drift") + + applications_without_web = [ + "contract-api-migrate", + "contract-api", + "contract-review-worker", + "contract-playbook-governance-worker", + "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", + "ai-platform-api", + "worker", + ] + expected_cases = [] + for profile_name, profiles, service_count, includes_web in ( + ("default", [], 13, False), + ("backend-hosted-web", ["backend-hosted-web"], 14, True), + ("ops", ["ops"], 15, False), + ("backend-hosted-web+ops", ["backend-hosted-web", "ops"], 16, True), + ): + applications = applications_without_web + (["contract-web"] if includes_web else []) + for mode in ("online", "offline"): + expected_cases.append((profile_name, profiles, mode, service_count, applications)) + cases = _require_list(report["cases"], "Compose gate cases") + if len(cases) != gate_policy["case_count"] or len(cases) != len(expected_cases): + raise ReleaseValidationError("production Compose gate did not run exactly eight cases") + config_digests: list[str] = [] + for index, (raw_case, expected) in enumerate(zip(cases, expected_cases, strict=True)): + case = _require_mapping(raw_case, f"Compose gate case {index}") + _require_exact_keys( + case, + { + "mode", + "profile_case", + "profiles", + "service_count", + "validated_application_services", + "config_sha256", + }, + f"Compose gate case {index}", + ) + profile_name, profiles, mode, service_count, applications = expected + if ( + case["profile_case"] != profile_name + or case["profiles"] != profiles + or case["mode"] != mode + or case["service_count"] != service_count + or case["validated_application_services"] != applications + ): + raise ReleaseValidationError(f"production Compose gate case {index} drift") + config_digest = case["config_sha256"] + if not isinstance(config_digest, str) or not SHA256_RE.fullmatch(config_digest): + raise ReleaseValidationError(f"production Compose gate case {index} digest is invalid") + config_digests.append(config_digest) + if len(set(config_digests)) != len(config_digests): + raise ReleaseValidationError("production Compose gate cases produced duplicate models") + return { + "schema_version": report["schema_version"], + "case_count": len(cases), + "compose_inputs": validated_inputs, + "config_sha256s": config_digests, + "synthetic_environment": True, + "production_secrets_used": False, + } + + +def parse_rfc3339(value: Any, *, label: str) -> datetime: + if not isinstance(value, str) or not RFC3339_RE.fullmatch(value): + raise ReleaseValidationError(f"{label} must be a non-empty RFC3339 timestamp") + normalized = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as error: + raise ReleaseValidationError(f"{label} is not RFC3339: {value!r}") from error + if parsed.tzinfo is None: + raise ReleaseValidationError(f"{label} must include a timezone") + return parsed.astimezone(timezone.utc) + + +def format_rfc3339(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def validate_source_manifest(payload: Any, *, commit: str) -> dict[str, Any]: + manifest = _require_mapping(payload, "source manifest") + if manifest.get("schema_version") != SOURCE_MANIFEST_SCHEMA: + raise ReleaseValidationError("source manifest schema is not the immutable RC source schema") + if not FULL_COMMIT_RE.fullmatch(commit): + raise ReleaseValidationError("commit must be a full lowercase 40-character Git object id") + claimed_payload_digest = manifest.get("manifest_payload_sha256") + if not isinstance(claimed_payload_digest, str) or not SHA256_RE.fullmatch(claimed_payload_digest): + raise ReleaseValidationError("source manifest payload digest is missing or invalid") + unsigned = dict(manifest) + del unsigned["manifest_payload_sha256"] + actual_payload_digest = sha256_bytes( + json.dumps(unsigned, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ) + if actual_payload_digest != claimed_payload_digest: + raise ReleaseValidationError("source manifest payload digest mismatch") + + candidate = _require_mapping(manifest.get("candidate"), "source manifest candidate") + for key in ("commit", "tree", "commit_timestamp", "worktree_was_clean", "identity_policy"): + if key not in candidate: + raise ReleaseValidationError(f"source manifest candidate is missing {key}") + if candidate["commit"] != commit: + raise ReleaseValidationError( + f"source manifest commit {candidate['commit']!r} does not match explicit commit {commit}" + ) + if not GIT_OBJECT_RE.fullmatch(str(candidate["tree"])): + raise ReleaseValidationError("source manifest tree object id is invalid") + parse_rfc3339(candidate["commit_timestamp"], label="candidate.commit_timestamp") + if candidate["worktree_was_clean"] is not True: + raise ReleaseValidationError("source manifest does not attest a clean candidate") + if candidate["identity_policy"] != "full_commit_oid_equals_HEAD": + raise ReleaseValidationError("source manifest identity policy is not strict") + + files = _require_mapping(manifest.get("files"), "source manifest files") + for key in ("count", "total_size", "content_index_sha256", "items"): + if key not in files: + raise ReleaseValidationError(f"source manifest files is missing {key}") + items = _require_list(files["items"], "source manifest files.items") + if ( + not isinstance(files["count"], int) + or isinstance(files["count"], bool) + or files["count"] < 0 + ): + raise ReleaseValidationError("source manifest file count is invalid") + if ( + not isinstance(files["total_size"], int) + or isinstance(files["total_size"], bool) + or files["total_size"] < 0 + ): + raise ReleaseValidationError("source manifest total size is invalid") + if not isinstance(files["content_index_sha256"], str) or not SHA256_RE.fullmatch( + files["content_index_sha256"] + ): + raise ReleaseValidationError("source manifest content index digest is invalid") + records: list[dict[str, Any]] = [] + seen: set[str] = set() + seen_casefold: set[str] = set() + for index, raw_record in enumerate(items): + record = _require_mapping(raw_record, f"source manifest files.items[{index}]") + _require_exact_keys( + record, + {"path", "mode", "git_blob_oid", "size", "sha256"}, + f"source manifest files.items[{index}]", + ) + path = record["path"] + validate_relative_path(path, label=f"source file path {index}") + folded = path.casefold() + if path in seen or folded in seen_casefold: + raise ReleaseValidationError(f"duplicate or case-colliding source path: {path}") + seen.add(path) + seen_casefold.add(folded) + if record["mode"] not in {"100644", "100755"}: + raise ReleaseValidationError(f"unsupported source file mode for {path}: {record['mode']!r}") + if not GIT_OBJECT_RE.fullmatch(str(record["git_blob_oid"])): + raise ReleaseValidationError(f"invalid Git blob object id for {path}") + if not isinstance(record["size"], int) or isinstance(record["size"], bool) or record["size"] < 0: + raise ReleaseValidationError(f"invalid source file size for {path}") + if not isinstance(record["sha256"], str) or not SHA256_RE.fullmatch(record["sha256"]): + raise ReleaseValidationError(f"invalid source file SHA-256 for {path}") + records.append(record) + sorted_paths = sorted((record["path"] for record in records), key=lambda item: item.encode("utf-8")) + if [record["path"] for record in records] != sorted_paths: + raise ReleaseValidationError("source manifest files are not in canonical UTF-8 path order") + if files["count"] != len(records): + raise ReleaseValidationError("source manifest file count mismatch") + if files["total_size"] != sum(record["size"] for record in records): + raise ReleaseValidationError("source manifest total size mismatch") + content_index = "\n".join( + f"{record['path']}\0{record['mode']}\0{record['size']}\0{record['sha256']}" + for record in records + ).encode("utf-8") + if files["content_index_sha256"] != sha256_bytes(content_index): + raise ReleaseValidationError("source manifest content index digest mismatch") + return manifest + + +def load_source_manifest(path: Path, *, commit: str) -> dict[str, Any]: + return validate_source_manifest(load_json_file(path, label="source manifest"), commit=commit) + + +def _git_blob_oid(content: bytes, expected_length: int) -> str: + framed = b"blob " + str(len(content)).encode("ascii") + b"\0" + content + if expected_length == 40: + return hashlib.sha1(framed).hexdigest() # noqa: S324 - Git SHA-1 object identity, not security. + if expected_length == 64: + return hashlib.sha256(framed).hexdigest() + raise ReleaseValidationError("unsupported Git object id length") + + +def _archive_member_path(name: str, *, label: str) -> str: + value = name[:-1] if name.endswith("/") else name + validate_relative_path(value, label=label) + return value + + +def _expected_parent_directories(paths: Iterable[str]) -> set[str]: + result: set[str] = set() + for value in paths: + parent = PurePosixPath(value).parent + while parent != PurePosixPath("."): + result.add(parent.as_posix()) + parent = parent.parent + return result + + +def _validate_tar_stream_termination(path: Path) -> None: + """Reject truncation and non-zero data hidden after the tar end marker.""" + + with path.open("rb") as probe: + magic = probe.read(6) + if magic.startswith(b"\x1f\x8b"): + handle: BinaryIO = gzip.open(path, "rb") + elif magic.startswith((b"BZh", b"\xfd7zXZ")): + raise ReleaseValidationError("source archives must be uncompressed tar or gzip-compressed tar") + else: + handle = path.open("rb") + with handle: + while True: + header = handle.read(512) + if len(header) != 512: + raise ReleaseValidationError("source archive lacks a complete tar end marker") + if header == b"\0" * 512: + second = handle.read(512) + if second != b"\0" * 512: + raise ReleaseValidationError("source archive has an incomplete tar end marker") + while remainder := handle.read(1024 * 1024): + if any(remainder): + raise ReleaseValidationError("source archive contains non-zero trailing data") + if len(remainder) % 512: + raise ReleaseValidationError("source archive trailing padding is not block aligned") + return + raw_size = header[124:136] + try: + if raw_size[0] & 0x80: + size = int.from_bytes(bytes([raw_size[0] & 0x7F]) + raw_size[1:], "big") + else: + text_size = raw_size.rstrip(b"\0 ").lstrip(b" ") or b"0" + size = int(text_size, 8) + except (ValueError, IndexError) as error: + raise ReleaseValidationError("source archive contains an invalid tar size field") from error + if size < 0: + raise ReleaseValidationError("source archive contains a negative tar member size") + remaining = ((size + 511) // 512) * 512 + while remaining: + chunk = handle.read(min(remaining, 1024 * 1024)) + if not chunk: + raise ReleaseValidationError("source archive is truncated within a member") + remaining -= len(chunk) + + +def verify_source_archive( + archive_path: Path, + manifest: dict[str, Any], + *, + extract_to: Path | None = None, +) -> dict[str, Any]: + if archive_path.is_symlink() or not archive_path.is_file(): + raise ReleaseValidationError("source archive must be a regular non-symlink file") + _validate_tar_stream_termination(archive_path) + records = {item["path"]: item for item in manifest["files"]["items"]} + expected_paths = set(records) + expected_mtime = int(parse_rfc3339( + manifest["candidate"]["commit_timestamp"], label="candidate.commit_timestamp" + ).timestamp()) + try: + archive = tarfile.open(archive_path, mode="r:*") + except (OSError, tarfile.TarError) as error: + raise ReleaseValidationError(f"source archive is not a readable tar archive: {error}") from error + with archive: + members = archive.getmembers() + if not members: + raise ReleaseValidationError("source archive is empty") + seen_raw: set[str] = set() + seen_folded: set[str] = set() + normalized: list[tuple[tarfile.TarInfo, str]] = [] + file_names: list[str] = [] + for index, member in enumerate(members): + name = _archive_member_path(member.name, label=f"archive member {index}") + folded = name.casefold() + if name in seen_raw or folded in seen_folded: + raise ReleaseValidationError(f"duplicate or case-colliding archive member: {name}") + seen_raw.add(name) + seen_folded.add(folded) + if not (member.isfile() or member.isdir()): + raise ReleaseValidationError( + f"source archive contains a link or special entry: {name} ({member.type!r})" + ) + if member.uid != 0 or member.gid != 0 or member.uname not in {"", "root"} or member.gname not in {"", "root"}: + raise ReleaseValidationError(f"archive ownership is not deterministic root:root for {name}") + if member.mtime != expected_mtime: + raise ReleaseValidationError( + f"archive timestamp mismatch for {name}: {member.mtime} != {expected_mtime}" + ) + normalized.append((member, name)) + if member.isfile(): + file_names.append(name) + + prefix = "" + if set(file_names) != expected_paths: + roots = {name.split("/", 1)[0] for name in file_names if "/" in name} + if len(roots) != 1: + raise ReleaseValidationError("archive paths do not exactly match the source manifest") + candidate_prefix = next(iter(roots)) + "/" + stripped_files = [ + name[len(candidate_prefix) :] if name.startswith(candidate_prefix) else "" + for name in file_names + ] + if "" in stripped_files or set(stripped_files) != expected_paths: + raise ReleaseValidationError("archive paths do not exactly match the source manifest") + prefix = candidate_prefix + stripped_file_order = [name[len(prefix) :] for name in file_names] + if stripped_file_order != sorted(stripped_file_order, key=lambda item: item.encode("utf-8")): + raise ReleaseValidationError("archive files are not in canonical UTF-8 path order") + + expected_directories = _expected_parent_directories(expected_paths) + extracted: list[tuple[Path, int]] = [] + if extract_to is not None: + if extract_to.exists() or extract_to.is_symlink(): + raise ReleaseValidationError("archive extraction destination must not already exist") + extract_to.mkdir(parents=True) + observed_files: set[str] = set() + for member, raw_name in normalized: + if prefix: + if raw_name == prefix[:-1]: + if not member.isdir(): + raise ReleaseValidationError("archive root prefix is not a directory") + continue + if not raw_name.startswith(prefix): + raise ReleaseValidationError(f"archive member escapes the single root prefix: {raw_name}") + relative_name = raw_name[len(prefix) :] + else: + relative_name = raw_name + if not relative_name: + continue + validate_relative_path(relative_name, label="stripped archive member") + if member.isdir(): + if relative_name not in expected_directories: + raise ReleaseValidationError(f"unexpected directory in source archive: {relative_name}") + if member.mode not in {0o755, 0o775}: + raise ReleaseValidationError(f"unexpected directory mode in source archive: {relative_name}") + if extract_to is not None: + destination = extract_to.joinpath(*PurePosixPath(relative_name).parts) + destination.mkdir(parents=True, exist_ok=True) + continue + + record = records.get(relative_name) + if record is None: + raise ReleaseValidationError(f"unexpected file in source archive: {relative_name}") + if member.size != record["size"]: + raise ReleaseValidationError(f"source archive size mismatch for {relative_name}") + expected_executable = record["mode"] == "100755" + if bool(member.mode & 0o111) != expected_executable or member.mode not in ( + {0o755, 0o775} if expected_executable else {0o644, 0o664} + ): + raise ReleaseValidationError(f"source archive mode mismatch for {relative_name}") + source = archive.extractfile(member) + if source is None: + raise ReleaseValidationError(f"cannot read archive member: {relative_name}") + content = source.read() + if len(content) != member.size or sha256_bytes(content) != record["sha256"]: + raise ReleaseValidationError(f"source archive content digest mismatch for {relative_name}") + expected_oid = record["git_blob_oid"] + if _git_blob_oid(content, len(expected_oid)) != expected_oid: + raise ReleaseValidationError(f"source archive Git blob identity mismatch for {relative_name}") + observed_files.add(relative_name) + if extract_to is not None: + destination = extract_to.joinpath(*PurePosixPath(relative_name).parts) + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as output: + output.write(content) + mode = 0o755 if expected_executable else 0o644 + os.chmod(destination, mode) + os.utime(destination, (expected_mtime, expected_mtime)) + extracted.append((destination, mode)) + if observed_files != expected_paths: + raise ReleaseValidationError( + f"source archive file set mismatch: missing={sorted(expected_paths - observed_files)}" + ) + if extract_to is not None: + for directory in sorted( + (path for path in extract_to.rglob("*") if path.is_dir()), + key=lambda path: len(path.parts), + reverse=True, + ): + os.chmod(directory, 0o755) + os.utime(directory, (expected_mtime, expected_mtime)) + os.utime(extract_to, (expected_mtime, expected_mtime)) + return { + "archive_sha256": sha256_file(archive_path), + "archive_size_bytes": archive_path.stat().st_size, + "file_count": len(expected_paths), + "root_prefix": prefix, + } + + +def _run_git( + repo: Path, + *arguments: str, + binary: bool = False, +) -> str | bytes: + command = [ + release_git_executable(), + "--no-replace-objects", + "-C", + str(repo), + *arguments, + ] + run_kwargs: dict[str, Any] = { + "check": False, + "capture_output": True, + "env": controlled_subprocess_env(GIT_VALIDATION_ENV), + "timeout": COMMAND_TIMEOUTS_SECONDS["git_validation"], + } + if not binary: + run_kwargs.update( + { + "text": True, + "encoding": "utf-8", + "errors": "replace", + } + ) + try: + completed = subprocess.run(command, **run_kwargs) + except subprocess.TimeoutExpired as error: + raise ReleaseValidationError( + "Git validation timed out after " + f"{COMMAND_TIMEOUTS_SECONDS['git_validation']} seconds: {' '.join(arguments)}" + ) from error + except OSError as error: + raise ReleaseValidationError(f"cannot execute Git validation: {error}") from error + if completed.returncode != 0: + stderr = completed.stderr + stdout = completed.stdout + if isinstance(stderr, bytes): + stderr = stderr.decode("utf-8", "replace") + if isinstance(stdout, bytes): + stdout = stdout.decode("utf-8", "replace") + message = str(stderr or "").strip() or str(stdout or "").strip() + raise ReleaseValidationError( + f"Git validation failed for {' '.join(arguments)}: {message}" + ) + if binary: + return bytes(completed.stdout) + return str(completed.stdout).strip() + + +def validate_candidate_checkout(repo: Path, *, commit: str) -> dict[str, Any]: + """Require the exact clean checkout and reject every replacement ref. + + All object reads already use ``--no-replace-objects``. Rejecting the refs + as well makes the release evidence explicit and prevents a replacement + namespace from being mistaken for part of the approved candidate state. + """ + + if repo.is_symlink() or not repo.is_dir(): + raise ReleaseValidationError(f"Git repository does not exist or is a symlink: {repo}") + if not FULL_COMMIT_RE.fullmatch(commit): + raise ReleaseValidationError("commit must be a full lowercase 40-character Git object id") + head = _run_git(repo, "rev-parse", "--verify", "HEAD") + assert isinstance(head, str) + if head != commit: + raise ReleaseValidationError(f"repository HEAD {head} does not equal candidate commit {commit}") + replacement_refs = _run_git( + repo, + "for-each-ref", + "--format=%(refname)", + "refs/replace/", + ) + assert isinstance(replacement_refs, str) + if replacement_refs: + raise ReleaseValidationError("candidate repository contains forbidden refs/replace entries") + dirty = _run_git(repo, "status", "--porcelain=v1", "-z", "-uall", binary=True) + assert isinstance(dirty, bytes) + if dirty: + raise ReleaseValidationError("candidate repository working tree is dirty") + return { + "head": head, + "worktree_clean": True, + "replace_refs_present": False, + "status_sha256": sha256_bytes(dirty), + } + + +def validate_manifest_commit_tree( + repo: Path, + *, + commit: str, + manifest: dict[str, Any], +) -> dict[str, Any]: + """Bind every manifest file record to the immutable Git commit tree.""" + + if repo.is_symlink() or not repo.is_dir(): + raise ReleaseValidationError(f"Git repository does not exist or is a symlink: {repo}") + if not FULL_COMMIT_RE.fullmatch(commit): + raise ReleaseValidationError("commit must be a full lowercase 40-character Git object id") + raw = _run_git(repo, "ls-tree", "-r", "-z", "-l", commit, binary=True) + assert isinstance(raw, bytes) + actual: list[dict[str, Any]] = [] + for index, record in enumerate(raw.split(b"\0")): + if not record: + continue + try: + metadata, raw_path = record.split(b"\t", 1) + mode, object_type, object_id, raw_size = metadata.decode("ascii").split() + path = raw_path.decode("utf-8", "strict") + except (UnicodeError, ValueError) as error: + raise ReleaseValidationError( + f"Git tree entry {index} is malformed or not UTF-8" + ) from error + if object_type != "blob" or raw_size == "-": + raise ReleaseValidationError( + f"Git tree contains unsupported entry {path!r}: {object_type} {raw_size}" + ) + validate_relative_path(path, label=f"Git tree path {index}") + try: + size = int(raw_size) + except ValueError as error: + raise ReleaseValidationError(f"Git tree size is invalid for {path}") from error + actual.append( + { + "path": path, + "mode": mode, + "git_blob_oid": object_id, + "size": size, + } + ) + actual.sort(key=lambda item: item["path"].encode("utf-8")) + expected = [ + { + "path": item["path"], + "mode": item["mode"], + "git_blob_oid": item["git_blob_oid"], + "size": item["size"], + } + for item in manifest["files"]["items"] + ] + if actual != expected: + actual_by_path = {item["path"]: item for item in actual} + expected_by_path = {item["path"]: item for item in expected} + missing = sorted(set(actual_by_path) - set(expected_by_path)) + extra = sorted(set(expected_by_path) - set(actual_by_path)) + changed = sorted( + path + for path in set(actual_by_path) & set(expected_by_path) + if actual_by_path[path] != expected_by_path[path] + ) + raise ReleaseValidationError( + "source manifest is not the candidate Git tree: " + f"missing_from_manifest={missing}, extra_in_manifest={extra}, changed={changed}" + ) + return { + "entry_count": len(actual), + "ls_tree_sha256": sha256_bytes(raw), + } + + +def validate_tag_binding(repo: Path, *, tag: str, commit: str, expected_tree: str) -> dict[str, str]: + if repo.is_symlink() or not repo.is_dir(): + raise ReleaseValidationError(f"Git repository does not exist: {repo}") + if not FULL_COMMIT_RE.fullmatch(commit): + raise ReleaseValidationError("commit must be a full lowercase 40-character Git object id") + if ( + not TAG_RE.fullmatch(tag) + or tag.endswith((".", "/")) + or ".." in tag + or "//" in tag + or "@{" in tag + or "/." in tag + ): + raise ReleaseValidationError(f"tag name is unsafe or ambiguous: {tag!r}") + + def git(*arguments: str) -> str: + result = _run_git(repo, *arguments) + assert isinstance(result, str) + return result + + ref = f"refs/tags/{tag}" + tag_object_oid = git("rev-parse", "--verify", ref) + if not GIT_OBJECT_RE.fullmatch(tag_object_oid): + raise ReleaseValidationError("annotated tag object id is invalid") + if git("show-ref", "--verify", ref) != f"{tag_object_oid} {ref}": + raise ReleaseValidationError("tag ref identity is ambiguous") + object_type = git("cat-file", "-t", ref) + if object_type != "tag": + raise ReleaseValidationError("release tag must be an annotated tag object") + actual_commit = git("rev-parse", "--verify", f"{ref}^{{commit}}") + if actual_commit != commit: + raise ReleaseValidationError(f"tag {tag} resolves to {actual_commit}, not {commit}") + direct_commit = git("rev-parse", "--verify", f"{commit}^{{commit}}") + if direct_commit != commit: + raise ReleaseValidationError("explicit commit does not resolve exactly") + tree = git("rev-parse", "--verify", f"{commit}^{{tree}}") + if tree != expected_tree: + raise ReleaseValidationError(f"Git tree {tree} does not match source manifest tree {expected_tree}") + return { + "tag": tag, + "tag_object_oid": tag_object_oid, + "commit": actual_commit, + "tree": tree, + "tag_object_type": object_type, + } + + +def ensure_regular_file_under(root: Path, relative_path: str) -> Path: + pure = validate_relative_path(relative_path, label="artifact path") + if root.is_symlink() or not root.is_dir(): + raise ReleaseValidationError("artifact root must be a real directory, not a symlink") + current = root + for part in pure.parts: + current = current / part + if current.is_symlink(): + raise ReleaseValidationError(f"artifact path traverses a symlink: {relative_path}") + if not current.is_file(): + raise ReleaseValidationError(f"artifact is missing or not a regular file: {relative_path}") + try: + current.resolve(strict=True).relative_to(root.resolve(strict=True)) + except (OSError, ValueError) as error: + raise ReleaseValidationError(f"artifact escapes its root: {relative_path}") from error + return current + + +def verify_file_record(root: Path, record: Any, *, label: str) -> Path: + item = _require_mapping(record, label) + for key in ("path", "sha256", "size_bytes"): + if key not in item: + raise ReleaseValidationError(f"{label} is missing {key}") + if not isinstance(item["sha256"], str) or not SHA256_RE.fullmatch(item["sha256"]): + raise ReleaseValidationError(f"{label} has an invalid SHA-256") + if not isinstance(item["size_bytes"], int) or isinstance(item["size_bytes"], bool) or item["size_bytes"] < 0: + raise ReleaseValidationError(f"{label} has an invalid size") + path = ensure_regular_file_under(root, item["path"]) + if path.stat().st_size != item["size_bytes"]: + raise ReleaseValidationError(f"{label} size drift: {item['path']}") + if sha256_file(path) != item["sha256"]: + raise ReleaseValidationError(f"{label} digest drift: {item['path']}") + return path + + +def validate_spdx(payload: Any, policy: dict[str, Any]) -> dict[str, Any]: + document = _require_mapping(payload, "SPDX SBOM") + if document.get("spdxVersion") != policy["sbom"]["spdx_version"]: + raise ReleaseValidationError("SBOM SPDX version mismatch") + if document.get("SPDXID") != "SPDXRef-DOCUMENT": + raise ReleaseValidationError("SBOM is missing SPDXRef-DOCUMENT identity") + namespace = document.get("documentNamespace") + if not isinstance(namespace, str) or not namespace: + raise ReleaseValidationError("SBOM document namespace is missing") + creation = _require_mapping(document.get("creationInfo"), "SPDX creationInfo") + creators = _require_list(creation.get("creators"), "SPDX creationInfo.creators") + if not any(isinstance(item, str) and item.casefold().startswith("tool: syft") for item in creators): + raise ReleaseValidationError("SBOM does not identify Syft as a creator") + packages = _require_list(document.get("packages"), "SPDX packages") + if not packages: + raise ReleaseValidationError("SBOM package inventory is empty") + return {"spdx_version": document["spdxVersion"], "package_count": len(packages)} + + +def validate_trivy_scan( + payload: Any, + policy: dict[str, Any], + *, + expected_config_digest: str | None = None, + expected_artifact_basename: str | None = None, + role: str | None = None, + at: datetime | None = None, +) -> dict[str, Any]: + report = _require_mapping(payload, "Trivy scan report") + schema_version = report.get("SchemaVersion") + if not isinstance(schema_version, int) or isinstance(schema_version, bool) or schema_version < 2: + raise ReleaseValidationError("Trivy scan SchemaVersion is missing or unsupported") + results = _require_list(report.get("Results"), "Trivy scan Results") + if not results: + raise ReleaseValidationError("Trivy scan has no Results and cannot prove an image was scanned") + artifact_name = report.get("ArtifactName") + if not isinstance(artifact_name, str) or not artifact_name: + raise ReleaseValidationError("Trivy scan ArtifactName is missing") + if expected_artifact_basename is not None and PurePosixPath( + artifact_name.replace("\\", "/") + ).name != expected_artifact_basename: + raise ReleaseValidationError("Trivy scan ArtifactName is not bound to the OCI archive") + metadata = _require_mapping(report.get("Metadata"), "Trivy scan Metadata") + image_id = metadata.get("ImageID") + if ( + not isinstance(image_id, str) + or not image_id.startswith("sha256:") + or not SHA256_RE.fullmatch(image_id[7:]) + ): + raise ReleaseValidationError("Trivy scan Metadata.ImageID is missing or invalid") + if expected_config_digest is not None and image_id != expected_config_digest: + raise ReleaseValidationError("Trivy scan image identity differs from the OCI config digest") + thresholds = policy["vulnerability_scan"]["thresholds"] + evaluated_at = (at or datetime.now(timezone.utc)).astimezone(timezone.utc) + os_metadata = metadata.get("OS") + os_family = os_metadata.get("Family") if isinstance(os_metadata, dict) else None + waiver_matchers: list[tuple[dict[str, Any], set[tuple[str, str, str]]]] = [] + for waiver in policy["vulnerability_scan"]["exemptions"]: + finding_keys = { + ( + finding["vulnerability_id"], + package_name, + finding["installed_version"], + ) + for finding in waiver["findings"] + for package_name in finding["package_names"] + } + waiver_matchers.append((waiver, finding_keys)) + total = {severity: 0 for severity in thresholds} + exempted = {severity: 0 for severity in thresholds} + blocking = {severity: 0 for severity in thresholds} + applied_waiver_counts: dict[str, int] = {} + consumed_waiver_findings: set[tuple[str, str, str, str]] = set() + for result_index, raw_result in enumerate(results): + result = _require_mapping(raw_result, f"Trivy Results[{result_index}]") + vulnerabilities = result.get("Vulnerabilities", []) + if vulnerabilities is None: + vulnerabilities = [] + vulnerabilities = _require_list(vulnerabilities, f"Trivy Results[{result_index}].Vulnerabilities") + for vulnerability_index, raw_vulnerability in enumerate(vulnerabilities): + vulnerability = _require_mapping( + raw_vulnerability, + f"Trivy vulnerability {result_index}/{vulnerability_index}", + ) + severity = vulnerability.get("Severity") + if severity not in thresholds: + raise ReleaseValidationError( + f"Trivy report contains severity outside the requested fail-closed set: {severity!r}" + ) + total[severity] += 1 + vulnerability_id = vulnerability.get("VulnerabilityID") + package_name = vulnerability.get("PkgName") + installed_version = vulnerability.get("InstalledVersion") + finding_key = ( + (vulnerability_id, package_name, installed_version) + if all( + isinstance(value, str) + for value in (vulnerability_id, package_name, installed_version) + ) + else None + ) + fixed_version = vulnerability.get("FixedVersion") + matched_waiver: dict[str, Any] | None = None + for waiver, allowed_findings in waiver_matchers: + scope = waiver["scope"] + expiry = parse_rfc3339( + waiver["expires_at"], label=f"vulnerability waiver {waiver['id']} expiry" + ) + consumed_key = ( + (waiver["id"], *finding_key) + if finding_key is not None + else None + ) + if ( + evaluated_at < expiry + and role in scope["roles"] + and result.get("Class") == scope["class"] + and result.get("Type") == scope["type"] + and os_family == scope["os_family"] + and severity == scope["severity"] + and scope["fixed_version_must_be_empty"] is True + and (fixed_version is None or fixed_version == "") + and finding_key is not None + and finding_key in allowed_findings + and consumed_key is not None + and consumed_key not in consumed_waiver_findings + ): + matched_waiver = waiver + consumed_waiver_findings.add(consumed_key) + break + if matched_waiver is None: + blocking[severity] += 1 + else: + exempted[severity] += 1 + waiver_id = matched_waiver["id"] + applied_waiver_counts[waiver_id] = applied_waiver_counts.get(waiver_id, 0) + 1 + passed = all(blocking[severity] <= limit for severity, limit in thresholds.items()) + if not passed: + raise ReleaseValidationError( + "Trivy vulnerability gate failed: " + f"total={total}, exempted={exempted}, blocking={blocking}" + ) + return { + "schema_version": schema_version, + "artifact_name": artifact_name, + "image_id": image_id, + "counts": { + "total": total, + "exempted": exempted, + "blocking": blocking, + }, + "waiver_ids": sorted(applied_waiver_counts), + "waiver_exempted_counts": { + waiver_id: applied_waiver_counts[waiver_id] + for waiver_id in sorted(applied_waiver_counts) + }, + "waiver_evaluated_at": format_rfc3339(evaluated_at), + "passed": True, + } + + +def validate_trivy_db_metadata( + payload: Any, + policy: dict[str, Any], + *, + at: datetime, +) -> dict[str, Any]: + metadata = _require_mapping(payload, "Trivy version/database metadata") + version = metadata.get("Version") + if version != REQUIRED_TOOL_VERSIONS["trivy"]: + raise ReleaseValidationError( + f"Trivy metadata version {version!r} is not pinned {REQUIRED_TOOL_VERSIONS['trivy']}" + ) + database = metadata.get("VulnerabilityDB") + database = _require_mapping(database, "Trivy VulnerabilityDB metadata") + database_version = database.get("Version") + expected_database_version = policy["vulnerability_scan"]["database"][ + "repository_transport" + ]["schema_version"] + if database_version != expected_database_version or isinstance(database_version, bool): + raise ReleaseValidationError( + "Trivy VulnerabilityDB.Version is missing or differs from the repository schema" + ) + updated = parse_rfc3339(database.get("UpdatedAt"), label="Trivy VulnerabilityDB.UpdatedAt") + next_update = parse_rfc3339(database.get("NextUpdate"), label="Trivy VulnerabilityDB.NextUpdate") + downloaded = parse_rfc3339(database.get("DownloadedAt"), label="Trivy VulnerabilityDB.DownloadedAt") + moment = at.astimezone(timezone.utc) + if updated > moment + timedelta(minutes=5) or downloaded > moment + timedelta(minutes=5): + raise ReleaseValidationError("Trivy database metadata is implausibly in the future") + maximum_age = timedelta(hours=policy["vulnerability_scan"]["database"]["maximum_age_hours"]) + if moment - updated > maximum_age: + raise ReleaseValidationError("Trivy vulnerability database is older than the allowed maximum age") + if policy["vulnerability_scan"]["database"]["require_next_update_after_scan"] and next_update <= moment: + raise ReleaseValidationError("Trivy vulnerability database is expired") + if downloaded < updated - timedelta(minutes=5): + raise ReleaseValidationError("Trivy database download predates its update metadata") + return { + "version": database_version, + "updated_at": format_rfc3339(updated), + "next_update": format_rfc3339(next_update), + "downloaded_at": format_rfc3339(downloaded), + "validated_at": format_rfc3339(moment), + } + + +def _read_tar_member(archive: tarfile.TarFile, name: str, *, label: str) -> bytes: + try: + member = archive.getmember(name) + except KeyError as error: + raise ReleaseValidationError(f"{label} is missing from OCI archive: {name}") from error + if not member.isfile(): + raise ReleaseValidationError(f"{label} is not a regular OCI archive member: {name}") + source = archive.extractfile(member) + if source is None: + raise ReleaseValidationError(f"cannot read {label}: {name}") + return source.read() + + +def _hash_tar_member(archive: tarfile.TarFile, name: str, *, label: str) -> tuple[int, str]: + """Hash one regular member without buffering an image layer in memory.""" + + try: + member = archive.getmember(name) + except KeyError as error: + raise ReleaseValidationError(f"{label} is missing from archive: {name}") from error + if not member.isfile(): + raise ReleaseValidationError(f"{label} is not a regular archive member: {name}") + source = archive.extractfile(member) + if source is None: + raise ReleaseValidationError(f"cannot read {label}: {name}") + digest = hashlib.sha256() + size = 0 + while chunk := source.read(1024 * 1024): + digest.update(chunk) + size += len(chunk) + if size != member.size: + raise ReleaseValidationError(f"{label} is truncated: {name}") + return size, digest.hexdigest() + + +def _docker_layer_diff_id( + archive: tarfile.TarFile, + name: str, + *, + label: str, + raw_digest: str, +) -> str: + """Return a Docker layer's uncompressed digest for config diff-id binding.""" + + member = archive.getmember(name) + source = archive.extractfile(member) + if source is None: + raise ReleaseValidationError(f"cannot read {label}: {name}") + magic = source.read(4) + source.close() + if magic.startswith(b"\x28\xb5\x2f\xfd"): + raise ReleaseValidationError( + f"{label} uses unsupported zstd compression and cannot be verified fail closed" + ) + if not magic.startswith(b"\x1f\x8b"): + return raw_digest + source = archive.extractfile(member) + if source is None: + raise ReleaseValidationError(f"cannot re-read {label}: {name}") + digest = hashlib.sha256() + try: + with gzip.GzipFile(fileobj=source, mode="rb") as uncompressed: + while chunk := uncompressed.read(1024 * 1024): + digest.update(chunk) + except (EOFError, OSError) as error: + raise ReleaseValidationError(f"{label} gzip payload is invalid: {name}") from error + return digest.hexdigest() + + +def _verify_oci_descriptor( + archive: tarfile.TarFile, + descriptor: Any, + *, + label: str, + read_content: bool = True, +) -> tuple[dict[str, Any], bytes | None]: + item = _require_mapping(descriptor, label) + digest = item.get("digest") + if not isinstance(digest, str) or not digest.startswith("sha256:") or not SHA256_RE.fullmatch(digest[7:]): + raise ReleaseValidationError(f"{label} has an invalid digest") + size = item.get("size") + if not isinstance(size, int) or isinstance(size, bool) or size < 0: + raise ReleaseValidationError(f"{label} has an invalid size") + name = f"blobs/sha256/{digest[7:]}" + actual_size, actual_digest = _hash_tar_member(archive, name, label=label) + if actual_size != size or actual_digest != digest[7:]: + raise ReleaseValidationError(f"{label} content digest or size mismatch") + data = _read_tar_member(archive, name, label=label) if read_content else None + return item, data + + +def _image_config_summary(config_data: bytes, *, label: str) -> dict[str, Any]: + config_payload = _require_mapping(load_json_bytes(config_data, label=label), label) + if config_payload.get("os") != "linux" or config_payload.get("architecture") != "amd64": + raise ReleaseValidationError(f"{label} platform is not linux/amd64") + runtime_config = _require_mapping(config_payload.get("config"), f"{label} runtime config") + labels = _require_mapping(runtime_config.get("Labels"), f"{label} labels") + required_labels = { + "revision": labels.get("org.opencontainers.image.revision"), + "version": labels.get("org.opencontainers.image.version"), + "release_role": labels.get("io.yuqei.release.role"), + "title": labels.get("org.opencontainers.image.title"), + } + if not all(isinstance(value, str) and value for value in required_labels.values()): + raise ReleaseValidationError(f"{label} lacks required candidate/role labels") + rootfs = _require_mapping(config_payload.get("rootfs"), f"{label} rootfs") + if rootfs.get("type") != "layers": + raise ReleaseValidationError(f"{label} rootfs type is not layers") + diff_ids = _require_list(rootfs.get("diff_ids"), f"{label} rootfs.diff_ids") + if not diff_ids or not all( + isinstance(value, str) + and value.startswith("sha256:") + and SHA256_RE.fullmatch(value[7:]) + for value in diff_ids + ): + raise ReleaseValidationError(f"{label} rootfs.diff_ids is empty or invalid") + return { + **required_labels, + "rootfs_diff_ids": diff_ids, + "rootfs_identity_sha256": sha256_bytes(canonical_json_bytes(diff_ids)), + } + + +def inspect_oci_archive(path: Path) -> dict[str, Any]: + if path.is_symlink() or not path.is_file(): + raise ReleaseValidationError("OCI archive must be a regular non-symlink file") + _validate_tar_stream_termination(path) + try: + archive = tarfile.open(path, mode="r:*") + except (OSError, tarfile.TarError) as error: + raise ReleaseValidationError(f"OCI archive is unreadable: {error}") from error + with archive: + seen: set[str] = set() + seen_folded: set[str] = set() + for index, member in enumerate(archive.getmembers()): + name = _archive_member_path(member.name, label=f"OCI archive member {index}") + if name in seen or name.casefold() in seen_folded: + raise ReleaseValidationError(f"duplicate or case-colliding OCI member: {name}") + seen.add(name) + seen_folded.add(name.casefold()) + if not (member.isfile() or member.isdir()): + raise ReleaseValidationError(f"OCI archive contains a link or special entry: {name}") + layout = _require_mapping( + load_json_bytes(_read_tar_member(archive, "oci-layout", label="OCI layout"), label="OCI layout"), + "OCI layout", + ) + if layout != {"imageLayoutVersion": "1.0.0"}: + raise ReleaseValidationError("unsupported OCI image layout version") + index = _require_mapping( + load_json_bytes(_read_tar_member(archive, "index.json", label="OCI index"), label="OCI index"), + "OCI index", + ) + descriptors = _require_list(index.get("manifests"), "OCI index manifests") + if len(descriptors) != 1: + raise ReleaseValidationError("OCI index must contain exactly one top-level descriptor") + descriptor, manifest_data = _verify_oci_descriptor( + archive, descriptors[0], label="OCI top-level descriptor" + ) + assert manifest_data is not None + media_type = descriptor.get("mediaType") + if media_type in { + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + }: + nested = _require_mapping(load_json_bytes(manifest_data, label="nested OCI index"), "nested OCI index") + nested_descriptors = _require_list(nested.get("manifests"), "nested OCI index manifests") + matches = [] + for candidate in nested_descriptors: + platform = _require_mapping( + _require_mapping(candidate, "nested OCI descriptor").get("platform"), + "nested OCI descriptor platform", + ) + if platform.get("os") == "linux" and platform.get("architecture") == "amd64": + matches.append(candidate) + if len(matches) != 1: + raise ReleaseValidationError("OCI archive does not contain exactly one linux/amd64 image") + descriptor, manifest_data = _verify_oci_descriptor( + archive, matches[0], label="OCI linux/amd64 manifest descriptor" + ) + assert manifest_data is not None + media_type = descriptor.get("mediaType") + else: + platform = descriptor.get("platform") + if isinstance(platform, dict) and ( + platform.get("os") != "linux" or platform.get("architecture") != "amd64" + ): + raise ReleaseValidationError("OCI descriptor platform is not linux/amd64") + if media_type not in { + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + }: + raise ReleaseValidationError(f"unsupported OCI image manifest media type: {media_type!r}") + manifest = _require_mapping(load_json_bytes(manifest_data, label="OCI image manifest"), "OCI image manifest") + config, config_data = _verify_oci_descriptor( + archive, manifest.get("config"), label="OCI config descriptor" + ) + assert config_data is not None + config_summary = _image_config_summary(config_data, label="OCI image config") + layers = _require_list(manifest.get("layers"), "OCI image layers") + if not layers: + raise ReleaseValidationError("OCI image has no layers") + if len(layers) != len(config_summary["rootfs_diff_ids"]): + raise ReleaseValidationError("OCI layer count differs from config rootfs.diff_ids") + for index, (layer, expected_diff_id) in enumerate( + zip(layers, config_summary["rootfs_diff_ids"], strict=True) + ): + verified_layer, _unused = _verify_oci_descriptor( + archive, layer, label=f"OCI layer {index}", read_content=False + ) + layer_digest = verified_layer["digest"] + layer_name = f"blobs/sha256/{layer_digest[7:]}" + actual_diff_id = _docker_layer_diff_id( + archive, + layer_name, + label=f"OCI layer {index}", + raw_digest=layer_digest[7:], + ) + if f"sha256:{actual_diff_id}" != expected_diff_id: + raise ReleaseValidationError( + f"OCI layer {index} content differs from config rootfs.diff_ids" + ) + return { + "manifest_digest": descriptor["digest"], + "manifest_media_type": media_type, + "config_digest": config["digest"], + **config_summary, + "layer_count": len(layers), + } + + +def inspect_docker_archive(path: Path, *, expected_repo_tag: str | None = None) -> dict[str, Any]: + if path.is_symlink() or not path.is_file(): + raise ReleaseValidationError("Docker archive must be a regular non-symlink file") + _validate_tar_stream_termination(path) + try: + archive = tarfile.open(path, mode="r:") + except (OSError, tarfile.TarError) as error: + raise ReleaseValidationError(f"Docker archive is unreadable: {error}") from error + with archive: + seen: set[str] = set() + seen_folded: set[str] = set() + for index, member in enumerate(archive.getmembers()): + name = _archive_member_path(member.name, label=f"Docker archive member {index}") + if name in seen or name.casefold() in seen_folded: + raise ReleaseValidationError(f"duplicate or case-colliding Docker archive member: {name}") + seen.add(name) + seen_folded.add(name.casefold()) + if not (member.isfile() or member.isdir()): + raise ReleaseValidationError( + f"Docker archive contains a link or special entry: {name}" + ) + manifest_payload = load_json_bytes( + _read_tar_member(archive, "manifest.json", label="Docker archive manifest"), + label="Docker archive manifest", + ) + manifests = _require_list(manifest_payload, "Docker archive manifest") + if len(manifests) != 1: + raise ReleaseValidationError("Docker archive must contain exactly one image") + manifest = _require_mapping(manifests[0], "Docker archive image manifest") + config_name = manifest.get("Config") + if not isinstance(config_name, str): + raise ReleaseValidationError("Docker archive manifest has no Config path") + validate_relative_path(config_name, label="Docker archive Config path") + config_data = _read_tar_member(archive, config_name, label="Docker image config") + config_sha256 = sha256_bytes(config_data) + if config_name not in { + f"{config_sha256}.json", + f"blobs/sha256/{config_sha256}", + }: + raise ReleaseValidationError("Docker archive config filename does not match its digest") + config_summary = _image_config_summary(config_data, label="Docker image config") + repo_tags = _require_list(manifest.get("RepoTags"), "Docker archive RepoTags") + if not repo_tags or not all(isinstance(item, str) and item for item in repo_tags): + raise ReleaseValidationError("Docker archive has no usable repository tag") + if len(repo_tags) != len(set(repo_tags)): + raise ReleaseValidationError("Docker archive has duplicate repository tags") + if expected_repo_tag is not None and repo_tags != [expected_repo_tag]: + raise ReleaseValidationError( + f"Docker archive repository tag drift: {repo_tags!r} != {[expected_repo_tag]!r}" + ) + layers = _require_list(manifest.get("Layers"), "Docker archive Layers") + if not layers or not all(isinstance(item, str) for item in layers): + raise ReleaseValidationError("Docker archive has no layers") + if len(layers) != len(set(layers)): + raise ReleaseValidationError("Docker archive has duplicate layer paths") + if len(layers) != len(config_summary["rootfs_diff_ids"]): + raise ReleaseValidationError( + "Docker archive layer count differs from config rootfs.diff_ids" + ) + for index, (layer_name, expected_diff_id) in enumerate( + zip(layers, config_summary["rootfs_diff_ids"], strict=True) + ): + validate_relative_path(layer_name, label=f"Docker layer path {index}") + _, raw_digest = _hash_tar_member( + archive, layer_name, label=f"Docker layer {index}" + ) + layer_path = PurePosixPath(layer_name) + is_digest_blob = ( + len(layer_path.parts) == 3 + and layer_path.parts[:2] == ("blobs", "sha256") + ) + if not is_digest_blob and layer_path.name != "layer.tar": + raise ReleaseValidationError( + f"Docker layer {index} path is not a supported loadable archive layout" + ) + if is_digest_blob and layer_path.name != raw_digest: + raise ReleaseValidationError( + f"Docker layer {index} blob path differs from its compressed digest" + ) + actual_diff_id = _docker_layer_diff_id( + archive, + layer_name, + label=f"Docker layer {index}", + raw_digest=raw_digest, + ) + if f"sha256:{actual_diff_id}" != expected_diff_id: + raise ReleaseValidationError( + f"Docker layer {index} content differs from config rootfs.diff_ids" + ) + return { + "config_digest": f"sha256:{config_sha256}", + "repo_tags": repo_tags, + **config_summary, + "layer_count": len(layers), + } + + +def validate_receipt_payload_digest(receipt: Any) -> dict[str, Any]: + result = _require_mapping(receipt, "build receipt") + if result.get("schema_version") != BUILD_RECEIPT_SCHEMA: + raise ReleaseValidationError("unsupported build receipt schema") + digest = result.get("receipt_payload_sha256") + if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest): + raise ReleaseValidationError("build receipt payload digest is invalid") + unsigned = dict(result) + del unsigned["receipt_payload_sha256"] + if sha256_bytes(canonical_json_bytes(unsigned)[:-1]) != digest: + raise ReleaseValidationError("build receipt payload digest mismatch") + return result + + +def with_payload_digest(payload: dict[str, Any], field: str) -> dict[str, Any]: + result = dict(payload) + result[field] = sha256_bytes(canonical_json_bytes(payload)[:-1]) + return result diff --git a/yuqei-infra/scripts/run-linux-release-build.py b/yuqei-infra/scripts/run-linux-release-build.py new file mode 100644 index 00000000..1000fd1d --- /dev/null +++ b/yuqei-infra/scripts/run-linux-release-build.py @@ -0,0 +1,1721 @@ +#!/usr/bin/env python3 +"""Build and scan the four RC application images on one pinned Linux/amd64 builder. + +``--plan`` and ``--dry-run`` perform all source provenance checks but never +invoke Docker, Syft, Trivy, or the network. A successful real run publishes +its output directory atomically and writes a canonical, self-hashed receipt. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) +# The repository must remain byte-for-byte clean while candidate identity is +# checked. Do not let importing the adjacent release helper create a tracked +# or untracked ``__pycache__`` side effect in a clean clone. +sys.dont_write_bytecode = True + +import linux_release_common as release_common # noqa: E402 + +from linux_release_common import ( # noqa: E402 + BUILD_RECEIPT_SCHEMA, + BUILDER_POLICY, + COMMAND_TIMEOUTS_SECONDS, + COMPOSE_GATE_POLICY, + CONTROLLED_SUBPROCESS_ENV, + LOCAL_DOCKER_HOST, + PINNED_EXECUTABLES, + REQUIRED_TOOL_VERSIONS, + TARGET, + TRIVY_DB_REPOSITORY_TRANSPORT, + ReleaseValidationError, + canonical_json_bytes, + controlled_subprocess_env, + file_record, + format_rfc3339, + inspect_docker_archive, + inspect_oci_archive, + load_json_bytes, + load_json_file, + load_policy, + load_source_manifest, + parse_rfc3339, + sha256_bytes, + sha256_file, + validate_spdx, + validate_compose_gate_report, + validate_candidate_checkout, + validate_buildx_config_root, + validate_manifest_commit_tree, + validate_pinned_executables, + validate_tag_binding, + validate_trivy_db_metadata, + validate_trivy_scan, + verify_source_archive, + synthetic_compose_gate_env_bytes, + with_payload_digest, +) + + +REPO_ROOT = SCRIPT_DIR.parents[1] +DEFAULT_POLICY_PATH = REPO_ROOT / "yuqei-infra/release/linux-release-security-policy.v1.json" +PLAN_SCHEMA = "yuqei.linux-release-build-plan.v1" +VERSION_RE = re.compile(r"(?:^|\s)v?([0-9]+\.[0-9]+\.[0-9]+)(?:\s|$)") +BUILDER_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}") +RUNNER_SOURCE_RELATIVE = "yuqei-infra/scripts/run-linux-release-build.py" +COMMON_SOURCE_RELATIVE = "yuqei-infra/scripts/linux_release_common.py" +POLICY_SOURCE_RELATIVE = "yuqei-infra/release/linux-release-security-policy.v1.json" +COMPOSE_CONFIG_MAX_BYTES = 8 * 1024 * 1024 +COMPOSE_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +COMPOSE_PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") +COMPOSE_IMAGE_REFERENCE_RE = re.compile( + r"^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$" +) +TRUSTED_APPLICATION_SERVICE_IMAGE_ENV: Mapping[str, str] = { + "contract-api-migrate": "CONTRACT_API_IMAGE", + "contract-api": "CONTRACT_API_IMAGE", + "contract-review-worker": "CONTRACT_API_IMAGE", + "contract-playbook-governance-worker": "CONTRACT_API_IMAGE", + "ai-platform-api-migrate": "AI_PLATFORM_API_IMAGE", + "ai-platform-prompt-assets-import": "AI_PLATFORM_API_IMAGE", + "ai-platform-api": "AI_PLATFORM_API_IMAGE", + "worker": "AI_WORKER_IMAGE", + "contract-web": "CONTRACT_WEB_IMAGE", +} +TRUSTED_PRODUCTION_SERVICES = ( + "postgres", + "postgres-init-databases", + "redis", + "minio", + "contract-api-migrate", + "contract-api", + "contract-review-worker", + "contract-playbook-governance-worker", + "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", + "ai-platform-api", + "worker", + "contract-web", + "edge", + "contract-files-backup", + "minio-backup", +) +TRUSTED_PROFILE_ONLY_SERVICES: Mapping[str, str] = { + "contract-web": "backend-hosted-web", + "contract-files-backup": "ops", + "minio-backup": "ops", +} +TRUSTED_PROFILE_CASES = ( + ("default", ()), + ("backend-hosted-web", ("backend-hosted-web",)), + ("ops", ("ops",)), + ("backend-hosted-web+ops", ("backend-hosted-web", "ops")), +) + + +def _archive_suffix(path: Path) -> str: + lowered = path.name.casefold() + if lowered.endswith(".tar.gz"): + return ".tar.gz" + if lowered.endswith(".tgz"): + return ".tgz" + if lowered.endswith(".tar"): + return ".tar" + return ".tar" + + +def _run_command( + arguments: Sequence[str], + *, + cwd: Path | None = None, + allowed_returncodes: set[int] | None = None, + env: Mapping[str, str] | None = None, + timeout_seconds: int = COMMAND_TIMEOUTS_SECONDS["tool_version"], +) -> subprocess.CompletedProcess[str]: + allowed = {0} if allowed_returncodes is None else allowed_returncodes + try: + completed = subprocess.run( + list(arguments), + cwd=cwd, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=controlled_subprocess_env() if env is None else dict(env), + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as error: + raise ReleaseValidationError( + f"release command timed out after {timeout_seconds} seconds: {' '.join(arguments)}" + ) from error + except OSError as error: + raise ReleaseValidationError( + f"cannot execute required release command {arguments[0]!r}: {error}" + ) from error + if completed.returncode not in allowed: + detail = (completed.stderr or completed.stdout).strip()[-4000:] + raise ReleaseValidationError( + f"command failed closed ({completed.returncode}): {' '.join(arguments)}\n{detail}" + ) + return completed + + +def _json_version(output: str, *, label: str) -> dict[str, Any]: + parsed = load_json_bytes(output.encode("utf-8"), label=label) + if not isinstance(parsed, dict): + raise ReleaseValidationError(f"{label} version output must be a JSON object") + return parsed + + +def _simple_version(output: str, *, label: str) -> str: + matches = VERSION_RE.findall(output) + if not matches: + raise ReleaseValidationError(f"cannot parse {label} version output: {output!r}") + return matches[0] + + +def parse_tool_versions(outputs: dict[str, str]) -> dict[str, str]: + """Parse and enforce every version in the controlled builder contract.""" + + docker = _json_version(outputs["docker"], label="docker version") + try: + docker_cli = str(docker["Client"]["Version"]) + docker_engine = str(docker["Server"]["Version"]) + except (KeyError, TypeError) as error: + raise ReleaseValidationError("docker version output lacks Client/Server.Version") from error + syft_payload = _json_version(outputs["syft"], label="Syft version") + trivy_payload = _json_version(outputs["trivy"], label="Trivy version") + syft_version = syft_payload.get("version", syft_payload.get("Version")) + trivy_version = trivy_payload.get("Version", trivy_payload.get("version")) + actual = { + "docker_engine": docker_engine, + "docker_cli": docker_cli, + "buildx": _simple_version(outputs["buildx"], label="Buildx"), + "compose": _simple_version(outputs["compose"], label="Compose"), + "syft": str(syft_version), + "trivy": str(trivy_version), + } + drift = { + tool: {"required": required, "actual": actual.get(tool)} + for tool, required in REQUIRED_TOOL_VERSIONS.items() + if actual.get(tool) != required + } + if drift: + raise ReleaseValidationError(f"builder toolchain version drift: {json.dumps(drift, sort_keys=True)}") + return actual + + +def validate_toolchain( + runner: Callable[..., subprocess.CompletedProcess[str]] = _run_command, +) -> dict[str, str]: + outputs = { + "docker": runner( + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "version", + "--format", + "{{json .}}", + ], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["tool_version"], + ).stdout, + "buildx": runner( + [PINNED_EXECUTABLES["docker"], "--host", LOCAL_DOCKER_HOST, "buildx", "version"], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["tool_version"], + ).stdout, + "compose": runner( + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "compose", + "version", + "--short", + ], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["tool_version"], + ).stdout, + "syft": runner( + [PINNED_EXECUTABLES["syft"], "version", "-o", "json"], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["tool_version"], + ).stdout, + "trivy": runner( + [PINNED_EXECUTABLES["trivy"], "version", "--format", "json"], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["tool_version"], + ).stdout, + } + return parse_tool_versions(outputs) + + +def parse_builder_inspect(output: str, *, builder_name: str) -> dict[str, Any]: + if not BUILDER_NAME_RE.fullmatch(builder_name): + raise ReleaseValidationError("Buildx builder name is missing or unsafe") + name_match = re.search(r"(?m)^Name:\s+(\S+)\s*$", output) + driver_match = re.search(r"(?m)^Driver:\s+(\S+)\s*$", output) + if not name_match or name_match.group(1) != builder_name or not driver_match: + raise ReleaseValidationError("Buildx inspect output does not identify the explicit builder") + driver = driver_match.group(1) + if driver not in BUILDER_POLICY["allowed_drivers"]: + raise ReleaseValidationError( + f"Buildx builder driver {driver!r} cannot satisfy the OCI/Docker exporter contract" + ) + if "Nodes:" not in output: + raise ReleaseValidationError("Buildx inspect output has no node inventory") + nodes = output.split("Nodes:", 1)[1] + node_names = re.findall(r"(?m)^Name:\s+(\S+)\s*$", nodes) + statuses = re.findall(r"(?m)^Status:\s+(\S+)\s*$", nodes) + buildkit_versions = re.findall(r"(?m)^BuildKit version:\s+v?(\S+)\s*$", nodes) + platform_lines = re.findall(r"(?m)^Platforms:\s+(.+?)\s*$", nodes) + maximum_nodes = BUILDER_POLICY["maximum_nodes"] + if not ( + len(node_names) == len(statuses) == len(buildkit_versions) == len(platform_lines) == maximum_nodes + ): + raise ReleaseValidationError( + "Buildx builder must expose exactly one fully described node for deterministic serial builds" + ) + if statuses != ["running"]: + raise ReleaseValidationError(f"Buildx builder node is not running: {statuses}") + platforms = [ + item.strip().rstrip("*") + for item in platform_lines[0].split(",") + if item.strip() + ] + if BUILDER_POLICY["required_platform"] not in platforms: + raise ReleaseValidationError("Buildx builder node does not advertise linux/amd64") + if buildkit_versions[0] != BUILDER_POLICY["required_buildkit_version"]: + raise ReleaseValidationError( + "BuildKit version drift: " + f"{buildkit_versions[0]!r} != {BUILDER_POLICY['required_buildkit_version']!r}" + ) + return { + "name": builder_name, + "driver": driver, + "node": node_names[0], + "node_status": statuses[0], + "buildkit_version": buildkit_versions[0], + "platforms": platforms, + "exporters": BUILDER_POLICY["required_exporters"], + "inspect_sha256": sha256_bytes(output.encode("utf-8")), + } + + +def parse_builder_container_inspect( + output: str, + *, + node_name: str, +) -> dict[str, Any]: + payload = load_json_bytes(output.encode("utf-8"), label="BuildKit driver container inspect") + if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict): + raise ReleaseValidationError("BuildKit driver container inspect must describe exactly one container") + container = payload[0] + expected_name = f"/buildx_buildkit_{node_name}" + if container.get("Name") != expected_name: + raise ReleaseValidationError( + f"BuildKit driver container name drift: {container.get('Name')!r} != {expected_name!r}" + ) + config = container.get("Config") + state = container.get("State") + if not isinstance(config, dict) or not isinstance(state, dict) or state.get("Running") is not True: + raise ReleaseValidationError("BuildKit driver container is not running or inspect is incomplete") + if config.get("Image") != BUILDER_POLICY["required_driver_image"]: + raise ReleaseValidationError("BuildKit driver image reference/digest drift") + if container.get("Image") != BUILDER_POLICY["required_local_driver_image_id"]: + raise ReleaseValidationError("BuildKit local driver image ID drift") + return { + "container_name": expected_name.removeprefix("/"), + "driver_image": config["Image"], + "driver_image_digest": BUILDER_POLICY["required_driver_image_digest"], + "local_driver_image_id": container["Image"], + "image_transport": "domestic_registry_mirror", + "container_inspect_sha256": sha256_bytes(output.encode("utf-8")), + } + + +def validate_buildx_builder( + builder_name: str, + runner: Callable[..., subprocess.CompletedProcess[str]] = _run_command, +) -> dict[str, Any]: + if not BUILDER_NAME_RE.fullmatch(builder_name): + raise ReleaseValidationError("--builder must be an explicit safe Buildx builder name") + config_root = validate_buildx_config_root(builder_name) + completed = runner( + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "inspect", + builder_name, + "--bootstrap", + ], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["builder_inspect"], + ) + summary = parse_builder_inspect(completed.stdout, builder_name=builder_name) + container_name = f"buildx_buildkit_{summary['node']}" + container = runner( + [PINNED_EXECUTABLES["docker"], "--host", LOCAL_DOCKER_HOST, "inspect", container_name], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["container_inspect"], + ) + summary.update( + parse_builder_container_inspect(container.stdout, node_name=summary["node"]) + ) + summary["config_root"] = config_root + return summary + + +def parse_imagetools_inspect( + output: str, + *, + transport_reference: str, + content_digest: str, +) -> dict[str, str]: + digest_match = re.search(r"(?m)^Digest:\s+(sha256:[0-9a-f]{64})\s*$", output) + media_match = re.search(r"(?m)^MediaType:\s+(\S+)\s*$", output) + if not digest_match or digest_match.group(1) != content_digest: + raise ReleaseValidationError( + f"base-image mirror digest drift for {transport_reference}" + ) + if not media_match or media_match.group(1) != "application/vnd.oci.image.manifest.v1+json": + raise ReleaseValidationError( + f"base-image mirror did not return one OCI image manifest for {transport_reference}" + ) + return { + "transport_reference": transport_reference, + "content_digest": content_digest, + "media_type": media_match.group(1), + "transport": "domestic_registry_mirror", + "inspect_sha256": sha256_bytes(output.encode("utf-8")), + } + + +def validate_base_image_transports( + policy: dict[str, Any], + runner: Callable[..., subprocess.CompletedProcess[str]] = _run_command, +) -> list[dict[str, str]]: + unique: dict[str, dict[str, Any]] = {} + for image in policy["images"]: + base = image["base_image"] + unique[base["transport_reference"]] = base + results = [] + for reference in sorted(unique): + completed = runner( + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "imagetools", + "inspect", + reference, + ], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["base_image_inspect"], + ) + results.append( + parse_imagetools_inspect( + completed.stdout, + transport_reference=reference, + content_digest=unique[reference]["content_digest"], + ) + ) + return results + + +def inspect_trivy_db_repository_transport( + policy: dict[str, Any], + runner: Callable[..., subprocess.CompletedProcess[str]] = _run_command, +) -> dict[str, Any]: + transport = policy["vulnerability_scan"]["database"]["repository_transport"] + reference = transport["reference"] + completed = runner( + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "imagetools", + "inspect", + reference, + ], + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["trivy_db_inspect"], + ) + digest_match = re.search(r"(?m)^Digest:\s+(sha256:[0-9a-f]{64})\s*$", completed.stdout) + media_match = re.search(r"(?m)^MediaType:\s+(\S+)\s*$", completed.stdout) + if not digest_match: + raise ReleaseValidationError("Trivy DB repository inspection has no valid resolved digest") + if not media_match or media_match.group(1) not in transport["allowed_media_types"]: + raise ReleaseValidationError("Trivy DB repository inspection has an unsupported media type") + return { + "reference": reference, + "transport": transport["transport"], + "schema_version": transport["schema_version"], + "resolved_digest": digest_match.group(1), + "media_type": media_match.group(1), + "inspect_sha256": sha256_bytes(completed.stdout.encode("utf-8")), + } + + +def _source_file_records(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {item["path"]: item for item in manifest["files"]["items"]} + + +def _git_blob_oid(data: bytes, hex_length: int) -> str: + algorithm = "sha1" if hex_length == 40 else "sha256" if hex_length == 64 else "" + if not algorithm: + raise ReleaseValidationError("candidate Git blob object id has an unsupported length") + digest = hashlib.new(algorithm) + digest.update(f"blob {len(data)}\0".encode("ascii")) + digest.update(data) + return digest.hexdigest() + + +def _bind_running_release_control( + *, + manifest: dict[str, Any], + relative_path: str, + running_path: Path, +) -> dict[str, Any]: + record = _source_file_records(manifest).get(relative_path) + if record is None: + raise ReleaseValidationError( + f"source manifest does not bind running release control: {relative_path}" + ) + lexical = Path(running_path).absolute() + if lexical.is_symlink() or not lexical.is_file(): + raise ReleaseValidationError( + f"running release control is missing or a symlink: {relative_path}" + ) + data = lexical.read_bytes() + raw_sha256 = sha256_bytes(data) + blob_oid = _git_blob_oid(data, len(str(record["git_blob_oid"]))) + if ( + len(data) != record["size"] + or raw_sha256 != record["sha256"] + or blob_oid != record["git_blob_oid"] + ): + raise ReleaseValidationError( + f"running release control differs from candidate manifest/Git blob: {relative_path}" + ) + return { + "path": relative_path, + "size_bytes": len(data), + "sha256": raw_sha256, + "git_blob_oid": blob_oid, + } + + +def validate_running_release_controls( + *, + manifest: dict[str, Any], + policy_path: Path, +) -> dict[str, dict[str, Any]]: + controls = { + "runner": (RUNNER_SOURCE_RELATIVE, Path(__file__)), + "common": (COMMON_SOURCE_RELATIVE, Path(str(release_common.__file__))), + "policy": (POLICY_SOURCE_RELATIVE, policy_path), + } + return { + name: _bind_running_release_control( + manifest=manifest, + relative_path=relative, + running_path=path, + ) + for name, (relative, path) in controls.items() + } + + +def _required_compose_gate_source_records( + manifest: dict[str, Any], +) -> dict[str, dict[str, Any]]: + records = _source_file_records(manifest) + required = [COMPOSE_GATE_POLICY["validator"], *COMPOSE_GATE_POLICY["compose_inputs"]] + missing = [path for path in required if path not in records] + if missing: + raise ReleaseValidationError( + f"source manifest lacks production Compose gate inputs: {missing}" + ) + return {path: records[path] for path in required} + + +def _strict_synthetic_compose_env(data: bytes) -> dict[str, str]: + try: + text = data.decode("utf-8") + except UnicodeError as error: + raise ReleaseValidationError( + "synthetic Compose gate environment is not UTF-8" + ) from error + values: dict[str, str] = {} + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export ") or "=" not in line: + raise ReleaseValidationError( + f"synthetic Compose gate environment has invalid syntax at line {line_number}" + ) + raw_key, raw_value = line.split("=", 1) + if raw_key != raw_key.strip(): + raise ReleaseValidationError( + f"synthetic Compose gate environment has invalid key spacing at line {line_number}" + ) + if not COMPOSE_ENV_KEY_RE.fullmatch(raw_key) or raw_key in values: + raise ReleaseValidationError( + f"synthetic Compose gate environment has invalid or duplicate key at line {line_number}" + ) + values[raw_key] = raw_value.strip() + project_name = values.get("COMPOSE_PROJECT_NAME") + if not isinstance(project_name, str) or not COMPOSE_PROJECT_RE.fullmatch(project_name): + raise ReleaseValidationError( + "synthetic Compose gate environment lacks a safe COMPOSE_PROJECT_NAME" + ) + for env_key in sorted(set(TRUSTED_APPLICATION_SERVICE_IMAGE_ENV.values())): + image = values.get(env_key) + if not isinstance(image, str) or not COMPOSE_IMAGE_REFERENCE_RE.fullmatch(image): + raise ReleaseValidationError( + f"synthetic Compose gate environment lacks immutable {env_key}" + ) + return values + + +def _reject_compose_json_constant(value: str) -> None: + raise ReleaseValidationError( + f"Docker Compose JSON contains forbidden numeric constant {value}" + ) + + +def _compose_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ReleaseValidationError( + f"Docker Compose JSON contains duplicate key {key!r}" + ) + result[key] = value + return result + + +def _parse_compose_model(stdout: Any) -> dict[str, Any]: + if isinstance(stdout, bytes): + try: + raw = stdout.decode("utf-8") + except UnicodeError as error: + raise ReleaseValidationError("Docker Compose emitted non-UTF-8 JSON") from error + elif isinstance(stdout, str): + raw = stdout + else: + raise ReleaseValidationError("Docker Compose did not emit textual JSON") + if len(raw.encode("utf-8")) > COMPOSE_CONFIG_MAX_BYTES: + raise ReleaseValidationError("Docker Compose JSON exceeds the maximum allowed size") + try: + payload = json.loads( + raw, + object_pairs_hook=_compose_json_object, + parse_constant=_reject_compose_json_constant, + ) + except ReleaseValidationError: + raise + except (TypeError, ValueError, json.JSONDecodeError) as error: + raise ReleaseValidationError("Docker Compose did not emit valid JSON") from error + if not isinstance(payload, dict): + raise ReleaseValidationError("Docker Compose JSON must be an object") + return payload + + +def _required_compose_services(profiles: Sequence[str]) -> set[str]: + active = set(profiles) + return { + service + for service in TRUSTED_PRODUCTION_SERVICES + if service not in TRUSTED_PROFILE_ONLY_SERVICES + or TRUSTED_PROFILE_ONLY_SERVICES[service] in active + } + + +def _validate_trusted_compose_model( + payload: Mapping[str, Any], + *, + env_values: Mapping[str, str], + mode: str, + profile_name: str, + profiles: Sequence[str], +) -> dict[str, Any]: + services = payload.get("services") + if not isinstance(services, dict): + raise ReleaseValidationError( + f"{mode}/{profile_name}: merged Compose model has no services object" + ) + actual_names = set(services) + required_names = _required_compose_services(profiles) + if actual_names != required_names: + raise ReleaseValidationError( + f"{mode}/{profile_name}: production service inventory drift: " + f"missing={sorted(required_names - actual_names)}, " + f"unexpected={sorted(actual_names - required_names)}" + ) + for service_name in sorted(actual_names): + if not isinstance(services[service_name], dict): + raise ReleaseValidationError( + f"{mode}/{profile_name}: service {service_name} is not an object" + ) + + validated_application_services: list[str] = [] + for service_name, env_key in TRUSTED_APPLICATION_SERVICE_IMAGE_ENV.items(): + if service_name not in services: + continue + service = services[service_name] + if service.get("image") != env_values[env_key]: + raise ReleaseValidationError( + f"{mode}/{profile_name}: service {service_name} image does not exactly match {env_key}" + ) + if service.get("build") is not None: + raise ReleaseValidationError( + f"{mode}/{profile_name}: service {service_name} retains a build definition" + ) + expected_pull = "never" if mode == "offline" else "always" + if service.get("pull_policy") != expected_pull: + raise ReleaseValidationError( + f"{mode}/{profile_name}: service {service_name} pull_policy must be {expected_pull}" + ) + validated_application_services.append(service_name) + + if mode == "offline": + for service_name in sorted(actual_names): + service = services[service_name] + if service.get("build") is not None: + raise ReleaseValidationError( + f"offline/{profile_name}: service {service_name} retains a build definition" + ) + if service.get("pull_policy") != "never": + raise ReleaseValidationError( + f"offline/{profile_name}: service {service_name} pull_policy must be never" + ) + + model_bytes = json.dumps( + payload, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return { + "mode": mode, + "profile_case": profile_name, + "profiles": list(profiles), + "service_count": len(actual_names), + "validated_application_services": validated_application_services, + "config_sha256": sha256_bytes(model_bytes), + } + + +def _trusted_compose_command( + *, + project_name: str, + env_path: Path, + compose_paths: Sequence[Path], + profiles: Sequence[str], +) -> list[str]: + command = [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "compose", + "--project-name", + project_name, + "--env-file", + str(env_path), + ] + for compose_path in compose_paths: + command.extend(("-f", str(compose_path))) + for profile in profiles: + command.extend(("--profile", profile)) + command.extend(("config", "--format", "json")) + return command + + +def run_production_compose_gate( + *, + source_root: Path, + staging: Path, + manifest: dict[str, Any], + policy: dict[str, Any], + runner: Callable[..., subprocess.CompletedProcess[str]] = _run_command, +) -> dict[str, Any]: + """Run a self-bound, read-only eight-case gate with deterministic fake values. + + The candidate validator remains an immutable reviewed input and is recorded + for audit continuity, but it is deliberately never executed or trusted to + attest its own result. The running release builder contains the gate logic + and is itself bound to the candidate manifest before this function runs. + """ + + source_records = _required_compose_gate_source_records(manifest) + validator_relative = COMPOSE_GATE_POLICY["validator"] + source_root_resolved = source_root.resolve(strict=True) + compose_paths: dict[str, Path] = {} + for relative, record in source_records.items(): + candidate = source_root.joinpath(*relative.split("/")) + if candidate.is_symlink() or not candidate.is_file(): + raise ReleaseValidationError( + f"candidate Compose gate input is missing or unsafe: {relative}" + ) + resolved = candidate.resolve(strict=True) + try: + resolved.relative_to(source_root_resolved) + except ValueError as error: + raise ReleaseValidationError( + f"candidate Compose gate input escapes the extracted source: {relative}" + ) from error + if resolved.stat().st_size != record["size"] or sha256_file(resolved) != record["sha256"]: + raise ReleaseValidationError( + f"candidate Compose gate input digest/size drift: {relative}" + ) + compose_paths[relative] = resolved + + env_relative = "validation/synthetic-compose-gate.env" + report_relative = "validation/production-compose-gate.json" + env_path = staging / env_relative + report_path = staging / report_relative + env_bytes = synthetic_compose_gate_env_bytes() + env_values = _strict_synthetic_compose_env(env_bytes) + _write_bytes_exclusive(env_path, env_bytes) + base_path, production_path, offline_path = ( + compose_paths[path] for path in COMPOSE_GATE_POLICY["compose_inputs"] + ) + cases: list[dict[str, Any]] = [] + for profile_name, profiles in TRUSTED_PROFILE_CASES: + for mode, selected_paths in ( + ("online", (base_path, production_path)), + ("offline", (base_path, production_path, offline_path)), + ): + command = _trusted_compose_command( + project_name=env_values["COMPOSE_PROJECT_NAME"], + env_path=env_path, + compose_paths=selected_paths, + profiles=profiles, + ) + completed = runner( + command, + cwd=source_root_resolved, + env=controlled_subprocess_env(), + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["compose_gate"], + ) + cases.append( + _validate_trusted_compose_model( + _parse_compose_model(completed.stdout), + env_values=env_values, + mode=mode, + profile_name=profile_name, + profiles=profiles, + ) + ) + report_payload = { + "schema_version": COMPOSE_GATE_POLICY["schema_version"], + "status": "passed", + "env_file_sha256": sha256_bytes(env_bytes), + "compose_inputs": [ + {"path": path, "sha256": source_records[path]["sha256"]} + for path in COMPOSE_GATE_POLICY["compose_inputs"] + ], + "application_service_count": len(TRUSTED_APPLICATION_SERVICE_IMAGE_ENV), + "production_service_count": len(TRUSTED_PRODUCTION_SERVICES), + "cases": cases, + } + summary = validate_compose_gate_report( + report_payload, + policy, + expected_env_sha256=sha256_bytes(env_bytes), + source_file_sha256={ + path: record["sha256"] for path, record in source_records.items() + }, + ) + _write_bytes_exclusive(report_path, canonical_json_bytes(report_payload)) + validator_record = source_records[validator_relative] + return { + "synthetic_environment": True, + "production_secrets_used": False, + "execution_engine": "self_bound_release_runner_builtin_v1", + "validator": { + "source_path": validator_relative, + "sha256": validator_record["sha256"], + "size_bytes": validator_record["size"], + }, + "environment": file_record(env_path, env_relative), + "report": file_record(report_path, report_relative, **summary), + } + + +def validate_release_inputs( + *, + repo: Path, + source_archive: Path, + source_manifest: Path, + commit: str, + tag: str, + policy_path: Path, +) -> dict[str, Any]: + policy = load_policy(policy_path) + manifest = load_source_manifest(source_manifest, commit=commit) + checkout = validate_candidate_checkout(repo, commit=commit) + commit_tree = validate_manifest_commit_tree(repo, commit=commit, manifest=manifest) + archive = verify_source_archive(source_archive, manifest) + release_controls = validate_running_release_controls( + manifest=manifest, + policy_path=policy_path, + ) + tag_binding = validate_tag_binding( + repo, + tag=tag, + commit=commit, + expected_tree=manifest["candidate"]["tree"], + ) + return { + "policy": policy, + "policy_sha256": sha256_file(policy_path), + "source_manifest": manifest, + "source_manifest_sha256": sha256_file(source_manifest), + "archive": archive, + "checkout": checkout, + "release_controls": release_controls, + "commit_tree": commit_tree, + "tag_binding": tag_binding, + } + + +def create_build_plan( + *, + repo: Path, + source_archive: Path, + source_manifest: Path, + commit: str, + tag: str, + builder_name: str, + output_dir: Path, + policy_path: Path = DEFAULT_POLICY_PATH, +) -> dict[str, Any]: + validated = validate_release_inputs( + repo=repo, + source_archive=source_archive, + source_manifest=source_manifest, + commit=commit, + tag=tag, + policy_path=policy_path, + ) + source_date_epoch = int( + parse_rfc3339( + validated["source_manifest"]["candidate"]["commit_timestamp"], + label="candidate.commit_timestamp", + ).timestamp() + ) + if not BUILDER_NAME_RE.fullmatch(builder_name): + raise ReleaseValidationError("--builder must be an explicit safe Buildx builder name") + source_file_records = { + item["path"]: item for item in validated["source_manifest"]["files"]["items"] + } + required_gate_sources = [ + COMPOSE_GATE_POLICY["validator"], + *COMPOSE_GATE_POLICY["compose_inputs"], + ] + missing_gate_sources = [path for path in required_gate_sources if path not in source_file_records] + if missing_gate_sources: + raise ReleaseValidationError( + f"source manifest lacks production Compose gate inputs: {missing_gate_sources}" + ) + synthetic_env_sha256 = sha256_bytes(synthetic_compose_gate_env_bytes()) + compose_gate_commands: list[list[str]] = [] + for _profile_name, profiles in TRUSTED_PROFILE_CASES: + for mode in ("online", "offline"): + compose_inputs = COMPOSE_GATE_POLICY["compose_inputs"][: 3 if mode == "offline" else 2] + command = [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "compose", + "--project-name", + "yuqei-rc04-structural-gate", + "--env-file", + "/validation/synthetic-compose-gate.env", + ] + for compose_input in compose_inputs: + command.extend(("-f", f"/{compose_input}")) + for profile in profiles: + command.extend(("--profile", profile)) + command.extend(("config", "--format", "json")) + compose_gate_commands.append(command) + images = [] + for sequence, spec in enumerate(validated["policy"]["images"], 1): + role = spec["role"] + image_ref = f"{spec['name']}:{commit}" + oci_path = f"images/{role}.oci.tar" + docker_path = f"images-docker/{role}.docker.tar" + sbom_path = f"sbom/{role}.spdx.json" + scan_path = f"scans/{role}.trivy.json" + images.append( + { + "sequence": sequence, + "role": role, + "image_ref": image_ref, + "environment_key": spec["environment_key"], + "dockerfile": spec["dockerfile"], + "base_image": spec["base_image"], + "outputs": { + "oci": oci_path, + "docker_archive": docker_path, + "sbom": sbom_path, + "scan": scan_path, + }, + "commands": [ + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "build", + "--builder", + builder_name, + "--platform", + TARGET["platform"], + "--file", + spec["dockerfile"], + "--tag", + image_ref, + "--label", + f"org.opencontainers.image.revision={commit}", + "--label", + f"org.opencontainers.image.version={tag}", + "--label", + f"org.opencontainers.image.title={spec['name']}", + "--label", + f"io.yuqei.release.role={role}", + "--build-arg", + f"SOURCE_DATE_EPOCH={source_date_epoch}", + "--build-arg", + "BUILDKIT_MULTI_PLATFORM=1", + "--build-arg", + ( + f"{spec['base_image']['build_arg']}=" + f"{spec['base_image']['transport_reference']}" + ), + "--pull", + "--no-cache", + "--provenance=false", + "--sbom=false", + "--output", + f"type=oci,dest=/{oci_path}", + "--output", + f"type=docker,dest=/{docker_path}", + ".", + ], + [ + PINNED_EXECUTABLES["syft"], + "scan", + f"oci-archive:/{oci_path}", + "--output", + f"spdx-json=/{sbom_path}", + ], + [ + PINNED_EXECUTABLES["trivy"], + "image", + "--cache-dir", + "/trivy-cache", + "--db-repository", + TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + "--input", + f"/{docker_path}", + "--scanners", + "vuln", + "--severity", + "HIGH,CRITICAL", + "--format", + "json", + "--output", + f"/{scan_path}", + "--exit-code", + "1", + "--ignore-unfixed=false", + "--skip-db-update", + "--skip-java-db-update", + ], + ], + } + ) + return { + "schema_version": PLAN_SCHEMA, + "execution": False, + "network_or_builder_tools_invoked": False, + "candidate": { + "commit": commit, + "tag": tag, + "tag_object_oid": validated["tag_binding"]["tag_object_oid"], + "tree": validated["source_manifest"]["candidate"]["tree"], + "commit_tree": validated["commit_tree"], + "source_manifest_sha256": validated["source_manifest_sha256"], + "source_manifest_payload_sha256": validated["source_manifest"]["manifest_payload_sha256"], + "source_archive_sha256": validated["archive"]["archive_sha256"], + "source_archive_root_prefix": validated["archive"]["root_prefix"], + }, + "policy": { + "path": str(policy_path), + "sha256": validated["policy_sha256"], + "target": TARGET, + "tool_versions": REQUIRED_TOOL_VERSIONS, + "pinned_executables": PINNED_EXECUTABLES, + "docker_host": LOCAL_DOCKER_HOST, + "controlled_environment": CONTROLLED_SUBPROCESS_ENV, + "command_timeouts_seconds": COMMAND_TIMEOUTS_SECONDS, + "trivy_db_repository_transport": TRIVY_DB_REPOSITORY_TRANSPORT, + }, + "candidate_checkout": validated["checkout"], + "release_controls": validated["release_controls"], + "output_dir": str(output_dir), + "builder": { + "name": builder_name, + "validation_at_execution": BUILDER_POLICY, + "implicit_default_allowed": False, + }, + "compose_gate": { + "execution_at_build": True, + "execution_engine": "self_bound_release_runner_builtin_v1", + "synthetic_environment": True, + "production_secrets_used": False, + "environment_sha256": synthetic_env_sha256, + "validator": { + "path": COMPOSE_GATE_POLICY["validator"], + "sha256": source_file_records[COMPOSE_GATE_POLICY["validator"]]["sha256"], + }, + "compose_inputs": [ + { + "path": path, + "sha256": source_file_records[path]["sha256"], + } + for path in COMPOSE_GATE_POLICY["compose_inputs"] + ], + "case_count": COMPOSE_GATE_POLICY["case_count"], + }, + "source_date_epoch": source_date_epoch, + "pre_build_commands": [ + ["validate", "pinned toolchain versions"], + *compose_gate_commands, + *[ + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "imagetools", + "inspect", + reference, + ] + for reference in sorted( + {image["base_image"]["transport_reference"] for image in validated["policy"]["images"]} + ) + ], + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "imagetools", + "inspect", + TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + ], + [ + PINNED_EXECUTABLES["trivy"], + "image", + "--cache-dir", + "/trivy-cache", + "--db-repository", + TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + "--download-db-only", + ], + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "imagetools", + "inspect", + TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + ], + [ + PINNED_EXECUTABLES["trivy"], + "version", + "--cache-dir", + "/trivy-cache", + "--format", + "json", + ], + ], + "parallelism": 1, + "images": images, + "post_build": ["validate every artifact", "write canonical self-hashed build receipt", "publish atomically"], + } + + +def _copy_input(source: Path, destination: Path) -> None: + if source.is_symlink() or not source.is_file(): + raise ReleaseValidationError(f"release input must be a regular non-symlink file: {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists() or destination.is_symlink(): + raise ReleaseValidationError(f"refusing to overwrite staged input: {destination}") + shutil.copyfile(source, destination, follow_symlinks=False) + + +def _write_bytes_exclusive(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("xb") as handle: + handle.write(data) + + +def _assert_real_builder_platform() -> None: + machine = platform.machine().casefold() + if not sys.platform.startswith("linux") or machine not in {"x86_64", "amd64"}: + raise ReleaseValidationError( + f"real release builds require Linux x86_64/amd64, found {sys.platform}/{platform.machine()}" + ) + + +def _safe_cleanup_staging(staging: Path, parent: Path) -> None: + try: + resolved = staging.resolve(strict=False) + resolved.relative_to(parent.resolve(strict=True)) + except (OSError, ValueError): + return + if staging.name.startswith(".linux-release-partial-") and staging.exists() and not staging.is_symlink(): + shutil.rmtree(staging) + + +def execute_build( + *, + repo: Path, + source_archive: Path, + source_manifest: Path, + commit: str, + tag: str, + builder_name: str, + output_dir: Path, + policy_path: Path = DEFAULT_POLICY_PATH, + runner: Callable[..., subprocess.CompletedProcess[str]] = _run_command, +) -> Path: + _assert_real_builder_platform() + executable_evidence = validate_pinned_executables() + validated = validate_release_inputs( + repo=repo, + source_archive=source_archive, + source_manifest=source_manifest, + commit=commit, + tag=tag, + policy_path=policy_path, + ) + tools = validate_toolchain(runner) + builder = validate_buildx_builder(builder_name, runner) + base_image_transports = validate_base_image_transports(validated["policy"], runner) + if output_dir.exists() or output_dir.is_symlink(): + raise ReleaseValidationError("output directory must not already exist") + output_parent = output_dir.parent + output_parent.mkdir(parents=True, exist_ok=True) + if output_parent.is_symlink() or not output_parent.is_dir(): + raise ReleaseValidationError("output parent must be a real directory") + staging = Path(tempfile.mkdtemp(prefix=".linux-release-partial-", dir=output_parent)) + started = datetime.now(timezone.utc) + policy = validated["policy"] + manifest = validated["source_manifest"] + source_date_epoch = int( + parse_rfc3339(manifest["candidate"]["commit_timestamp"], label="candidate.commit_timestamp").timestamp() + ) + try: + archive_relative = f"source/source-archive{_archive_suffix(source_archive)}" + manifest_relative = "source/source-manifest.json" + policy_relative = "policy/linux-release-security-policy.v1.json" + staged_archive = staging / archive_relative + staged_manifest = staging / manifest_relative + staged_policy = staging / policy_relative + _copy_input(source_archive, staged_archive) + _copy_input(source_manifest, staged_manifest) + _copy_input(policy_path, staged_policy) + # Re-parse the staged bytes so a source-side replacement between the + # initial validation and copy cannot influence the build unnoticed. + policy = load_policy(staged_policy) + manifest = load_source_manifest(staged_manifest, commit=commit) + if sha256_file(staged_policy) != validated["policy_sha256"]: + raise ReleaseValidationError("staged release policy differs from the validated input") + if sha256_file(staged_manifest) != validated["source_manifest_sha256"]: + raise ReleaseValidationError("staged source manifest differs from the validated input") + staged_archive_summary = verify_source_archive(staged_archive, manifest) + if staged_archive_summary != validated["archive"]: + raise ReleaseValidationError("staged source archive differs from the validated input") + + image_receipts: list[dict[str, Any]] = [] + with tempfile.TemporaryDirectory(prefix="yuqei-release-source-", dir=output_parent) as temp_source: + source_root = Path(temp_source) / "root" + trivy_cache = Path(temp_source) / "trivy-cache" + syft_cache = Path(temp_source) / "syft-cache" + trivy_cache.mkdir(mode=0o700) + syft_cache.mkdir(mode=0o700) + trivy_env = controlled_subprocess_env( + {"TRIVY_CACHE_DIR": str(trivy_cache)} + ) + syft_env = controlled_subprocess_env( + { + "SYFT_CHECK_FOR_APP_UPDATE": "false", + "XDG_CACHE_HOME": str(syft_cache), + } + ) + verify_source_archive(staged_archive, manifest, extract_to=source_root) + compose_gate_receipt = run_production_compose_gate( + source_root=source_root, + staging=staging, + manifest=manifest, + policy=policy, + runner=runner, + ) + + db_transport_before = inspect_trivy_db_repository_transport(policy, runner) + runner( + [ + PINNED_EXECUTABLES["trivy"], + "image", + "--cache-dir", + str(trivy_cache), + "--db-repository", + TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + "--download-db-only", + ], + env=trivy_env, + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["trivy_db_download"], + ) + db_transport_after = inspect_trivy_db_repository_transport(policy, runner) + if ( + db_transport_after["resolved_digest"] + != db_transport_before["resolved_digest"] + ): + raise ReleaseValidationError( + "Trivy DB repository tag changed while the database was downloaded" + ) + db_transport = { + "reference": db_transport_after["reference"], + "transport": db_transport_after["transport"], + "schema_version": db_transport_after["schema_version"], + "resolved_digest": db_transport_after["resolved_digest"], + "media_type": db_transport_after["media_type"], + "stable_during_download": True, + "before_download_inspect_sha256": db_transport_before["inspect_sha256"], + "after_download_inspect_sha256": db_transport_after["inspect_sha256"], + } + db_completed = runner( + [ + PINNED_EXECUTABLES["trivy"], + "version", + "--cache-dir", + str(trivy_cache), + "--format", + "json", + ], + env=trivy_env, + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["tool_version"], + ) + db_payload = load_json_bytes( + db_completed.stdout.encode("utf-8"), label="Trivy DB metadata" + ) + db_validated_at = datetime.now(timezone.utc) + db_summary = validate_trivy_db_metadata( + db_payload, policy, at=db_validated_at + ) + db_relative = "security/trivy-db-metadata.json" + db_path = staging / db_relative + _write_bytes_exclusive(db_path, canonical_json_bytes(db_payload)) + + for sequence, spec in enumerate(policy["images"], 1): + role = spec["role"] + dockerfile = source_root.joinpath(*spec["dockerfile"].split("/")) + if not dockerfile.is_file() or dockerfile.is_symlink(): + raise ReleaseValidationError(f"validated source lacks Dockerfile for {role}") + dockerfile_text = dockerfile.read_text(encoding="utf-8") + expected_arg = ( + f"ARG {spec['base_image']['build_arg']}=" + f"{spec['base_image']['source_reference']}" + ) + expected_from = f"FROM ${{{spec['base_image']['build_arg']}}}" + if expected_arg not in dockerfile_text.splitlines() or expected_from not in dockerfile_text.splitlines(): + raise ReleaseValidationError( + f"{role} Dockerfile base image does not match the digest-bound transport override policy" + ) + image_ref = f"{spec['name']}:{commit}" + oci_relative = f"images/{role}.oci.tar" + docker_relative = f"images-docker/{role}.docker.tar" + sbom_relative = f"sbom/{role}.spdx.json" + scan_relative = f"scans/{role}.trivy.json" + oci_path = staging / oci_relative + docker_path = staging / docker_relative + sbom_path = staging / sbom_relative + scan_path = staging / scan_relative + oci_path.parent.mkdir(parents=True, exist_ok=True) + docker_path.parent.mkdir(parents=True, exist_ok=True) + sbom_path.parent.mkdir(parents=True, exist_ok=True) + scan_path.parent.mkdir(parents=True, exist_ok=True) + + runner( + [ + PINNED_EXECUTABLES["docker"], + "--host", + LOCAL_DOCKER_HOST, + "buildx", + "build", + "--builder", + builder_name, + "--platform", + TARGET["platform"], + "--file", + str(dockerfile), + "--tag", + image_ref, + "--label", + f"org.opencontainers.image.revision={commit}", + "--label", + f"org.opencontainers.image.version={tag}", + "--label", + f"org.opencontainers.image.title={spec['name']}", + "--label", + f"io.yuqei.release.role={role}", + "--build-arg", + f"SOURCE_DATE_EPOCH={source_date_epoch}", + "--build-arg", + "BUILDKIT_MULTI_PLATFORM=1", + "--build-arg", + ( + f"{spec['base_image']['build_arg']}=" + f"{spec['base_image']['transport_reference']}" + ), + "--pull", + "--no-cache", + "--provenance=false", + "--sbom=false", + "--output", + f"type=oci,dest={oci_path}", + "--output", + f"type=docker,dest={docker_path}", + ".", + ], + cwd=source_root, + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["image_build"], + ) + oci_summary = inspect_oci_archive(oci_path) + if ( + oci_summary["revision"] != commit + or oci_summary["version"] != tag + or oci_summary["release_role"] != role + or oci_summary["title"] != spec["name"] + ): + raise ReleaseValidationError(f"{role} OCI labels are not bound to the candidate") + docker_summary = inspect_docker_archive( + docker_path, expected_repo_tag=image_ref + ) + if ( + docker_summary["config_digest"] != oci_summary["config_digest"] + or docker_summary["rootfs_diff_ids"] != oci_summary["rootfs_diff_ids"] + or docker_summary["rootfs_identity_sha256"] + != oci_summary["rootfs_identity_sha256"] + ): + raise ReleaseValidationError( + f"{role} OCI and Docker archives do not share one complete rootfs identity" + ) + if ( + docker_summary["revision"] != commit + or docker_summary["version"] != tag + or docker_summary["release_role"] != role + or docker_summary["title"] != spec["name"] + ): + raise ReleaseValidationError( + f"{role} Docker archive labels are not bound to the candidate" + ) + + runner( + [ + PINNED_EXECUTABLES["syft"], + "scan", + f"oci-archive:{oci_path}", + "--output", + f"spdx-json={sbom_path}", + ], + env=syft_env, + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["sbom_generation"], + ) + sbom_payload = load_json_file(sbom_path, label=f"{role} SPDX SBOM") + sbom_summary = validate_spdx(sbom_payload, policy) + + # A database that was fresh when the build began may expire while + # four images are built. Re-evaluate freshness immediately before + # every scan; --skip-db-update then holds all scans to those bytes. + db_summary = validate_trivy_db_metadata( + db_payload, policy, at=datetime.now(timezone.utc) + ) + scan_completed = runner( + [ + PINNED_EXECUTABLES["trivy"], + "image", + "--cache-dir", + str(trivy_cache), + "--db-repository", + TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + "--input", + str(docker_path), + "--scanners", + "vuln", + "--severity", + "HIGH,CRITICAL", + "--format", + "json", + "--output", + str(scan_path), + "--exit-code", + "1", + "--ignore-unfixed=false", + "--skip-db-update", + "--skip-java-db-update", + ], + allowed_returncodes={0, 1}, + env=trivy_env, + timeout_seconds=COMMAND_TIMEOUTS_SECONDS["vulnerability_scan"], + ) + scan_payload = load_json_file(scan_path, label=f"{role} Trivy scan") + scan_evaluated_at = datetime.now(timezone.utc) + scan_summary = validate_trivy_scan( + scan_payload, + policy, + expected_config_digest=oci_summary["config_digest"], + expected_artifact_basename=docker_path.name, + role=role, + at=scan_evaluated_at, + ) + expected_scan_returncode = ( + 1 + if any(scan_summary["counts"]["total"].values()) + else 0 + ) + if scan_completed.returncode != expected_scan_returncode: + raise ReleaseValidationError( + f"{role} Trivy return code {scan_completed.returncode} disagrees with " + f"the complete parsed finding count (expected {expected_scan_returncode})" + ) + image_receipts.append( + { + "sequence": sequence, + "role": role, + "image_name": spec["name"], + "environment_key": spec["environment_key"], + "image_ref": image_ref, + "dockerfile": spec["dockerfile"], + "base_image": spec["base_image"], + "platform": TARGET["platform"], + "oci": file_record(oci_path, oci_relative, **oci_summary), + "docker_archive": file_record( + docker_path, docker_relative, **docker_summary + ), + "sbom": file_record(sbom_path, sbom_relative, **sbom_summary), + "scan": file_record( + scan_path, + scan_relative, + scanner="trivy", + scanner_version=tools["trivy"], + **scan_summary, + ), + } + ) + + db_summary = validate_trivy_db_metadata( + db_payload, policy, at=datetime.now(timezone.utc) + ) + final_checkout = validate_candidate_checkout(repo, commit=commit) + if final_checkout != validated["checkout"]: + raise ReleaseValidationError("candidate checkout changed during the release build") + final_release_controls = validate_running_release_controls( + manifest=manifest, + policy_path=staged_policy, + ) + if final_release_controls != validated["release_controls"]: + raise ReleaseValidationError("running release controls changed during the release build") + final_tag_binding = validate_tag_binding( + repo, + tag=tag, + commit=commit, + expected_tree=manifest["candidate"]["tree"], + ) + if final_tag_binding != validated["tag_binding"]: + raise ReleaseValidationError("annotated tag object changed during the release build") + final_commit_tree = validate_manifest_commit_tree( + repo, commit=commit, manifest=manifest + ) + if final_commit_tree != validated["commit_tree"]: + raise ReleaseValidationError("candidate Git tree changed during the release build") + verify_source_archive(staged_archive, manifest) + completed_at = datetime.now(timezone.utc) + vulnerability_counts = { + bucket: { + severity: sum( + image["scan"]["counts"][bucket][severity] + for image in image_receipts + ) + for severity in policy["vulnerability_scan"]["thresholds"] + } + for bucket in ("total", "exempted", "blocking") + } + applied_waiver_ids = sorted( + { + waiver_id + for image in image_receipts + for waiver_id in image["scan"]["waiver_ids"] + } + ) + receipt_without_digest: dict[str, Any] = { + "schema_version": BUILD_RECEIPT_SCHEMA, + "candidate": { + "commit": commit, + "tag": tag, + "tag_object_oid": final_tag_binding["tag_object_oid"], + "tree": manifest["candidate"]["tree"], + "commit_tree": final_commit_tree, + "commit_timestamp": manifest["candidate"]["commit_timestamp"], + "source_manifest_payload_sha256": manifest["manifest_payload_sha256"], + "source_manifest": file_record(staged_manifest, manifest_relative), + "source_archive": file_record( + staged_archive, + archive_relative, + root_prefix=validated["archive"]["root_prefix"], + file_count=validated["archive"]["file_count"], + ), + }, + "policy": file_record( + staged_policy, + policy_relative, + schema_version=policy["schema_version"], + target=TARGET, + ), + "builder": { + "os": "linux", + "architecture": "amd64", + "target_platform": TARGET["platform"], + "tools": tools, + "executables": executable_evidence, + "controlled_environment": CONTROLLED_SUBPROCESS_ENV, + "docker_host": LOCAL_DOCKER_HOST, + "command_timeouts_seconds": COMMAND_TIMEOUTS_SECONDS, + "buildx_builder": builder, + "base_image_transports": base_image_transports, + "candidate_checkout": final_checkout, + "release_controls": final_release_controls, + "skopeo_used": False, + }, + "build": { + "started_at": format_rfc3339(started), + "completed_at": format_rfc3339(completed_at), + "source_date_epoch": source_date_epoch, + "parallelism": 1, + "image_order": [image["role"] for image in image_receipts], + "compose_gate": compose_gate_receipt, + }, + "security": { + "passed": True, + "fail_closed": True, + "thresholds": policy["vulnerability_scan"]["thresholds"], + "exemptions": policy["vulnerability_scan"]["exemptions"], + "vulnerability_counts": vulnerability_counts, + "waiver_ids": applied_waiver_ids, + "database": file_record( + db_path, + db_relative, + repository_transport=db_transport, + **db_summary, + ), + }, + "images": image_receipts, + "claims": [ + "The candidate Compose model passed eight synthetic, secret-free online/offline profile cases before image construction.", + "All four application images were built serially for linux/amd64.", + "Every image has OCI and Docker-loadable archives from one solve, an SPDX-2.3 SBOM, and a complete Trivy report with zero blocking HIGH/CRITICAL findings after only the policy's exact time-limited waivers.", + "This receipt does not claim deployment, migration, health, UAT, or public-release approval.", + ], + } + receipt = with_payload_digest(receipt_without_digest, "receipt_payload_sha256") + receipt_path = staging / "build-receipt.json" + _write_bytes_exclusive(receipt_path, canonical_json_bytes(receipt)) + os.replace(staging, output_dir) + return output_dir / "build-receipt.json" + except BaseException: + _safe_cleanup_staging(staging, output_parent) + raise + + +def _write_plan(path: Path, plan: dict[str, Any]) -> None: + if path.exists() or path.is_symlink(): + raise ReleaseValidationError("plan output already exists") + path.parent.mkdir(parents=True, exist_ok=True) + _write_bytes_exclusive(path, canonical_json_bytes(plan)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Git repository containing the exact tag") + parser.add_argument("--source-archive", type=Path, required=True) + parser.add_argument("--source-manifest", type=Path, required=True) + parser.add_argument("--commit", required=True, help="full lowercase 40-character commit object id") + parser.add_argument("--tag", required=True, help="immutable tag that must resolve to --commit") + parser.add_argument( + "--builder", + required=True, + help="explicit single-node docker-container Buildx builder; the implicit default is forbidden", + ) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY_PATH) + parser.add_argument("--plan", "--dry-run", action="store_true", dest="plan") + parser.add_argument("--plan-output", type=Path) + args = parser.parse_args(argv) + try: + if args.plan: + plan = create_build_plan( + repo=args.repo.resolve(), + source_archive=args.source_archive.resolve(), + source_manifest=args.source_manifest.resolve(), + commit=args.commit, + tag=args.tag, + builder_name=args.builder, + output_dir=args.output_dir.resolve(), + policy_path=args.policy.resolve(), + ) + if args.plan_output: + _write_plan(args.plan_output.resolve(), plan) + print(canonical_json_bytes(plan).decode("utf-8"), end="") + return 0 + receipt = execute_build( + repo=args.repo.resolve(), + source_archive=args.source_archive.resolve(), + source_manifest=args.source_manifest.resolve(), + commit=args.commit, + tag=args.tag, + builder_name=args.builder, + output_dir=args.output_dir.resolve(), + policy_path=args.policy.resolve(), + ) + print(json.dumps({"status": "passed", "receipt": str(receipt)}, sort_keys=True)) + return 0 + except ReleaseValidationError as error: + print(f"linux_release_build_error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/yuqei-infra/tests/test_installable_release_assembler_hardening.py b/yuqei-infra/tests/test_installable_release_assembler_hardening.py new file mode 100644 index 00000000..9cbcfec8 --- /dev/null +++ b/yuqei-infra/tests/test_installable_release_assembler_hardening.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import importlib.util +import errno +import os +import stat +import sys +from pathlib import Path +from typing import Any + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_module(path: Path, name: str) -> Any: + existing = sys.modules.get(name) + if existing is not None: + return existing + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +assembler = _load_module( + REPO_ROOT / "yuqei-infra/scripts/build-installable-release.py", + "installable_release_assembler_hardening", +) +build_helpers = _load_module( + REPO_ROOT / "yuqei-infra/tests/test_linux_release_build_tools.py", + "installable_release_assembler_fixture_helpers", +) + + +def test_out_of_band_receipt_sha_mismatch_rejects_without_output(tmp_path: Path) -> None: + bundle = build_helpers._bundle(tmp_path) + output = bundle["root"] / "installable-release-manifest.json" + + with pytest.raises( + assembler.ReleaseValidationError, + match="independently supplied raw SHA-256", + ): + assembler.assemble_installable_release( + artifact_root=bundle["root"], + receipt_path=bundle["receipt_path"], + expected_receipt_sha256="0" * 64, + repo=bundle["repo"], + output=output, + ) + + assert not output.exists() + + +def test_exclusive_writer_refuses_to_overwrite_existing_file(tmp_path: Path) -> None: + output = tmp_path / "installable-release-manifest.json" + output.write_bytes(b"trusted-existing-bytes") + + with pytest.raises( + assembler.ReleaseValidationError, + match="must not already exist", + ): + assembler._write_exclusive_manifest(output, {"new": "payload"}) + + assert output.read_bytes() == b"trusted-existing-bytes" + + +def test_exclusive_writer_refuses_symlink_without_touching_target( + tmp_path: Path, + monkeypatch, +) -> None: + target = tmp_path / "trusted-target.json" + target.write_bytes(b"trusted-target-bytes") + output = tmp_path / "installable-release-manifest.json" + try: + output.symlink_to(target) + except OSError: + original_open = os.open + + def simulated_no_follow(path, flags, mode=0o777, *, dir_fd=None): + if Path(path) == output: + assert flags & getattr(os, "O_NOFOLLOW", 0) or os.name == "nt" + raise OSError(errno.ELOOP, "simulated no-follow symlink rejection") + if dir_fd is None: + return original_open(path, flags, mode) + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(assembler.os, "open", simulated_no_follow) + + with pytest.raises( + assembler.ReleaseValidationError, + match="must not already exist|must not be a symbolic link", + ): + assembler._write_exclusive_manifest(output, {"new": "payload"}) + + if output.exists(): + assert output.is_symlink() + assert target.read_bytes() == b"trusted-target-bytes" + + +def test_exclusive_writer_rejects_link_count_change(tmp_path: Path, monkeypatch) -> None: + output = tmp_path / "installable-release-manifest.json" + original_fstat = os.fstat + + def forged_fstat(descriptor: int) -> os.stat_result: + result = original_fstat(descriptor) + values = list(result) + values[3] = 2 + return os.stat_result(values) + + monkeypatch.setattr(assembler.os, "fstat", forged_fstat) + with pytest.raises( + assembler.ReleaseValidationError, + match="single-link regular file", + ): + assembler._write_exclusive_manifest(output, {"new": "payload"}) + + assert output.exists() + assert output.read_bytes() == b"" + + +def test_exclusive_writer_rejects_output_path_identity_change( + tmp_path: Path, + monkeypatch, +) -> None: + output = tmp_path / "installable-release-manifest.json" + original_lstat = os.lstat + + def forged_lstat(path: os.PathLike[str] | str) -> os.stat_result: + result = original_lstat(path) + if Path(path) == output: + values = list(result) + values[1] = int(result.st_ino) + 1 + return os.stat_result(values) + return result + + monkeypatch.setattr(assembler.os, "lstat", forged_lstat) + with pytest.raises( + assembler.ReleaseValidationError, + match="output path changed identity", + ): + assembler._write_exclusive_manifest(output, {"new": "payload"}) + + +def test_exclusive_writer_is_owner_only_and_fsyncs_file_and_directory( + tmp_path: Path, + monkeypatch, +) -> None: + output = tmp_path / "installable-release-manifest.json" + original_fsync = os.fsync + fsync_calls: list[int] = [] + + def recording_fsync(descriptor: int) -> None: + fsync_calls.append(descriptor) + original_fsync(descriptor) + + monkeypatch.setattr(assembler.os, "fsync", recording_fsync) + assembler._write_exclusive_manifest(output, {"schema_version": "test.v1"}) + + assert output.read_bytes() == assembler.canonical_json_bytes( + {"schema_version": "test.v1"} + ) + assert len(fsync_calls) >= (1 if os.name == "nt" else 2) + if os.name != "nt": + assert stat.S_IMODE(output.stat().st_mode) == 0o600 diff --git a/yuqei-infra/tests/test_linux_release_build_tools.py b/yuqei-infra/tests/test_linux_release_build_tools.py new file mode 100644 index 00000000..3afb170e --- /dev/null +++ b/yuqei-infra/tests/test_linux_release_build_tools.py @@ -0,0 +1,1869 @@ +from __future__ import annotations + +import hashlib +import gzip +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tarfile +from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path, PurePosixPath +from types import ModuleType +from typing import Any + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = REPO_ROOT / "yuqei-infra/scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import linux_release_common as common # noqa: E402 + + +def _load(name: str, relative_path: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, REPO_ROOT / relative_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +builder = _load("run_linux_release_build_test", "yuqei-infra/scripts/run-linux-release-build.py") +assembler = _load("build_installable_release_test", "yuqei-infra/scripts/build-installable-release.py") + + +POLICY_PATH = REPO_ROOT / "yuqei-infra/release/linux-release-security-policy.v1.json" + + +def _git(repo: Path, *args: str, env: dict[str, str] | None = None) -> str: + completed = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + env=env, + ) + return completed.stdout.strip() + + +def _git_bytes(repo: Path, *args: str) -> bytes: + completed = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + ) + return completed.stdout + + +def _write(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def _reseal_source_manifest(manifest: dict[str, Any]) -> dict[str, Any]: + files = manifest["files"] + items = files["items"] + files["count"] = len(items) + files["total_size"] = sum(item["size"] for item in items) + content_index = "\n".join( + f"{item['path']}\0{item['mode']}\0{item['size']}\0{item['sha256']}" + for item in items + ).encode("utf-8") + files["content_index_sha256"] = hashlib.sha256(content_index).hexdigest() + manifest.pop("manifest_payload_sha256", None) + manifest["manifest_payload_sha256"] = hashlib.sha256( + json.dumps( + manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + return manifest + + +def _fixture_source(tmp_path: Path) -> dict[str, Any]: + repo = tmp_path / "repo" + repo.mkdir(parents=True) + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "release-test@example.invalid") + _git(repo, "config", "user.name", "Release Test") + _git(repo, "config", "core.autocrlf", "false") + _git(repo, "config", "core.eol", "lf") + files = { + "README.md": b"fixture release\n", + "yuqei-infra/docker/contract-web.Dockerfile": b"FROM scratch\n", + "yuqei-infra/docker/contract-api.Dockerfile": b"FROM scratch\n", + "yuqei-infra/docker/ai-platform-api.Dockerfile": b"FROM scratch\n", + "yuqei-infra/docker/worker.Dockerfile": b"FROM scratch\n", + builder.RUNNER_SOURCE_RELATIVE: Path(builder.__file__).read_bytes(), + builder.COMMON_SOURCE_RELATIVE: Path(common.__file__).read_bytes(), + builder.POLICY_SOURCE_RELATIVE: POLICY_PATH.read_bytes(), + } + for relative in ( + common.COMPOSE_GATE_POLICY["validator"], + *common.COMPOSE_GATE_POLICY["compose_inputs"], + ): + files[relative] = (REPO_ROOT / relative).read_bytes() + for name, data in files.items(): + _write(repo.joinpath(*PurePosixPath(name).parts), data) + _git(repo, "add", ".") + commit_env = os.environ.copy() + commit_env.update( + { + "GIT_AUTHOR_DATE": "2026-09-20T00:00:00Z", + "GIT_COMMITTER_DATE": "2026-09-20T00:00:00Z", + } + ) + _git(repo, "-c", "commit.gpgsign=false", "commit", "-q", "-m", "fixture", env=commit_env) + commit = _git(repo, "rev-parse", "HEAD") + tag = "v2-fixture-rc04.4" + _git(repo, "tag", "-a", tag, "-m", "fixture annotated release tag", env=commit_env) + tag_object_oid = _git(repo, "rev-parse", f"refs/tags/{tag}") + timestamp = _git(repo, "show", "-s", "--format=%cI", commit) + epoch = int(_git(repo, "show", "-s", "--format=%ct", commit)) + tree = _git(repo, "rev-parse", f"{commit}^{{tree}}") + committed_files = { + name: _git_bytes(repo, "show", f"{commit}:{name}") for name in files + } + items = [] + for name in sorted(committed_files, key=lambda value: value.encode("utf-8")): + data = committed_files[name] + items.append( + { + "path": name, + "mode": "100644", + "git_blob_oid": _git(repo, "rev-parse", f"{commit}:{name}"), + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + ) + content_index = "\n".join( + f"{item['path']}\0{item['mode']}\0{item['size']}\0{item['sha256']}" for item in items + ).encode() + unsigned_manifest = { + "schema_version": common.SOURCE_MANIFEST_SCHEMA, + "candidate": { + "commit": commit, + "commit_timestamp": timestamp, + "tree": tree, + "worktree_was_clean": True, + "identity_policy": "full_commit_oid_equals_HEAD", + }, + "files": { + "count": len(items), + "total_size": sum(item["size"] for item in items), + "content_index_sha256": hashlib.sha256(content_index).hexdigest(), + "items": items, + }, + "summaries": {}, + "claims_not_made": [], + } + manifest = dict(unsigned_manifest) + manifest["manifest_payload_sha256"] = hashlib.sha256( + json.dumps( + unsigned_manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode() + ).hexdigest() + manifest_path = tmp_path / "source-manifest.json" + manifest_path.write_bytes(common.canonical_json_bytes(manifest)) + + archive_path = tmp_path / "source.tar" + subprocess.run( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.eol=lf", + "-C", + str(repo), + "archive", + "--format=tar", + f"--output={archive_path}", + commit, + ], + check=True, + ) + return { + "repo": repo, + "commit": commit, + "tag": tag, + "tag_object_oid": tag_object_oid, + "tree": tree, + "timestamp": timestamp, + "epoch": epoch, + "manifest": manifest, + "manifest_path": manifest_path, + "archive_path": archive_path, + } + + +def _image_layer(role: str) -> bytes: + return f"layer-for-{role}".encode() + + +def _image_config(role: str, image_name: str, commit: str, tag: str) -> bytes: + layer = _image_layer(role) + return json.dumps( + { + "architecture": "amd64", + "os": "linux", + "rootfs": { + "type": "layers", + "diff_ids": [f"sha256:{hashlib.sha256(layer).hexdigest()}"], + }, + "config": { + "Labels": { + "org.opencontainers.image.revision": commit, + "org.opencontainers.image.version": tag, + "org.opencontainers.image.title": image_name, + "io.yuqei.release.role": role, + } + }, + "role": role, + }, + sort_keys=True, + ).encode() + + +def _write_oci( + path: Path, role: str, image_name: str, commit: str, tag: str +) -> dict[str, Any]: + config = _image_config(role, image_name, commit, tag) + layer = _image_layer(role) + config_digest = hashlib.sha256(config).hexdigest() + layer_digest = hashlib.sha256(layer).hexdigest() + manifest_payload = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": f"sha256:{config_digest}", + "size": len(config), + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": f"sha256:{layer_digest}", + "size": len(layer), + } + ], + } + manifest = json.dumps(manifest_payload, sort_keys=True, separators=(",", ":")).encode() + manifest_digest = hashlib.sha256(manifest).hexdigest() + index = json.dumps( + { + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": f"sha256:{manifest_digest}", + "size": len(manifest), + "platform": {"os": "linux", "architecture": "amd64"}, + } + ], + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + entries = { + "oci-layout": b'{"imageLayoutVersion":"1.0.0"}', + "index.json": index, + f"blobs/sha256/{config_digest}": config, + f"blobs/sha256/{layer_digest}": layer, + f"blobs/sha256/{manifest_digest}": manifest, + } + path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(path, "w") as archive: + for name, data in sorted(entries.items()): + info = tarfile.TarInfo(name) + info.mode = 0o644 + info.size = len(data) + archive.addfile(info, BytesIO(data)) + return common.inspect_oci_archive(path) + + +def _write_docker_archive( + path: Path, + role: str, + image_name: str, + commit: str, + tag: str, + image_ref: str, +) -> dict[str, Any]: + config = _image_config(role, image_name, commit, tag) + config_digest = hashlib.sha256(config).hexdigest() + layer_name = f"layers/{role}/layer.tar" + layer = _image_layer(role) + manifest = json.dumps( + [ + { + "Config": f"{config_digest}.json", + "RepoTags": [image_ref], + "Layers": [layer_name], + } + ], + sort_keys=True, + separators=(",", ":"), + ).encode() + entries = { + "manifest.json": manifest, + f"{config_digest}.json": config, + layer_name: layer, + } + path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(path, "w") as archive: + for name, data in sorted(entries.items()): + info = tarfile.TarInfo(name) + info.mode = 0o644 + info.size = len(data) + archive.addfile(info, BytesIO(data)) + return common.inspect_docker_archive(path, expected_repo_tag=image_ref) + + +def _spdx(role: str) -> dict[str, Any]: + return { + "spdxVersion": "SPDX-2.3", + "SPDXID": "SPDXRef-DOCUMENT", + "name": role, + "documentNamespace": f"https://example.invalid/spdx/{role}", + "creationInfo": {"creators": ["Tool: syft-1.52.0"], "created": "2026-09-20T00:05:00Z"}, + "packages": [{"SPDXID": f"SPDXRef-Package-{role}", "name": role}], + } + + +def _scan( + role: str, + vulnerabilities: list[dict[str, Any]] | None = None, + *, + image_id: str = "sha256:" + "a" * 64, + os_family: str = "debian", +) -> dict[str, Any]: + return { + "SchemaVersion": 2, + "CreatedAt": "2026-09-20T00:05:00Z", + "ArtifactName": role, + "ArtifactType": "container_image", + "Metadata": {"ImageID": image_id, "OS": {"Family": os_family, "Name": "13.7"}}, + "Results": [ + { + "Target": role, + "Class": "os-pkgs", + "Type": "debian", + "Vulnerabilities": [] if vulnerabilities is None else vulnerabilities, + } + ], + } + + +def _waived_python_vulnerabilities() -> list[dict[str, Any]]: + waiver = common.VULNERABILITY_WAIVERS[0] + return [ + { + "VulnerabilityID": finding["vulnerability_id"], + "PkgName": package_name, + "InstalledVersion": finding["installed_version"], + "FixedVersion": "", + "Severity": "HIGH", + } + for finding in waiver["findings"] + for package_name in finding["package_names"] + ] + + +def _compose_gate_report(source: dict[str, Any]) -> tuple[bytes, dict[str, Any]]: + env_bytes = common.synthetic_compose_gate_env_bytes() + source_records = { + item["path"]: item for item in source["manifest"]["files"]["items"] + } + applications_without_web = [ + "contract-api-migrate", + "contract-api", + "contract-review-worker", + "contract-playbook-governance-worker", + "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", + "ai-platform-api", + "worker", + ] + cases = [] + case_index = 0 + for profile_name, profiles, service_count, includes_web in ( + ("default", [], 13, False), + ("backend-hosted-web", ["backend-hosted-web"], 14, True), + ("ops", ["ops"], 15, False), + ("backend-hosted-web+ops", ["backend-hosted-web", "ops"], 16, True), + ): + applications = applications_without_web + (["contract-web"] if includes_web else []) + for mode in ("online", "offline"): + case_index += 1 + cases.append( + { + "mode": mode, + "profile_case": profile_name, + "profiles": profiles, + "service_count": service_count, + "validated_application_services": applications, + "config_sha256": f"{case_index:x}" * 64, + } + ) + report = { + "schema_version": common.COMPOSE_GATE_POLICY["schema_version"], + "status": "passed", + "env_file_sha256": hashlib.sha256(env_bytes).hexdigest(), + "compose_inputs": [ + {"path": path, "sha256": source_records[path]["sha256"]} + for path in common.COMPOSE_GATE_POLICY["compose_inputs"] + ], + "application_service_count": 9, + "production_service_count": 16, + "cases": cases, + } + return env_bytes, report + + +def _synthetic_compose_env_values() -> dict[str, str]: + values: dict[str, str] = {} + for raw_line in common.synthetic_compose_gate_env_bytes().decode("utf-8").splitlines(): + line = raw_line.strip() + if line and not line.startswith("#"): + key, value = line.split("=", 1) + values[key] = value + return values + + +def _compose_model( + *, mode: str, profile_name: str, profiles: tuple[str, ...] +) -> dict[str, Any]: + env_values = _synthetic_compose_env_values() + services: dict[str, dict[str, Any]] = {} + for service_name in builder.TRUSTED_PRODUCTION_SERVICES: + required_profile = builder.TRUSTED_PROFILE_ONLY_SERVICES.get(service_name) + if required_profile is not None and required_profile not in profiles: + continue + service: dict[str, Any] = { + "pull_policy": "never" if mode == "offline" else "always" + } + env_key = builder.TRUSTED_APPLICATION_SERVICE_IMAGE_ENV.get(service_name) + if env_key is not None: + service["image"] = env_values[env_key] + services[service_name] = service + return { + "name": f"fixture-{mode}-{profile_name}", + "services": services, + } + + +def _bundle(tmp_path: Path) -> dict[str, Any]: + source = _fixture_source(tmp_path) + root = tmp_path / "bundle" + (root / "source").mkdir(parents=True) + shutil.copyfile(source["archive_path"], root / "source/source-archive.tar") + shutil.copyfile(source["manifest_path"], root / "source/source-manifest.json") + (root / "policy").mkdir() + shutil.copyfile(POLICY_PATH, root / "policy/linux-release-security-policy.v1.json") + policy = common.load_policy(POLICY_PATH) + db = { + "Version": "0.74.0", + "VulnerabilityDB": { + "Version": 2, + "UpdatedAt": "2026-09-20T00:00:00Z", + "NextUpdate": "2026-09-20T06:00:00Z", + "DownloadedAt": "2026-09-20T00:01:00Z", + }, + } + db_path = root / "security/trivy-db-metadata.json" + _write(db_path, common.canonical_json_bytes(db)) + db_summary = common.validate_trivy_db_metadata( + db, policy, at=datetime(2026, 9, 20, 0, 2, tzinfo=timezone.utc) + ) + compose_env_bytes, compose_report = _compose_gate_report(source) + compose_env_path = root / "validation/synthetic-compose-gate.env" + compose_report_path = root / "validation/production-compose-gate.json" + _write(compose_env_path, compose_env_bytes) + _write(compose_report_path, common.canonical_json_bytes(compose_report)) + source_records = { + item["path"]: item for item in source["manifest"]["files"]["items"] + } + release_controls = { + name: { + "path": relative, + "size_bytes": source_records[relative]["size"], + "sha256": source_records[relative]["sha256"], + "git_blob_oid": source_records[relative]["git_blob_oid"], + } + for name, relative in ( + ("runner", builder.RUNNER_SOURCE_RELATIVE), + ("common", builder.COMMON_SOURCE_RELATIVE), + ("policy", builder.POLICY_SOURCE_RELATIVE), + ) + } + compose_summary = common.validate_compose_gate_report( + compose_report, + policy, + expected_env_sha256=hashlib.sha256(compose_env_bytes).hexdigest(), + source_file_sha256={path: item["sha256"] for path, item in source_records.items()}, + ) + images = [] + for sequence, spec in enumerate(common.REQUIRED_IMAGE_SPECS, 1): + role = spec["role"] + oci_path = root / f"images/{role}.oci.tar" + oci_summary = _write_oci( + oci_path, role, spec["name"], source["commit"], source["tag"] + ) + image_ref = f"{spec['name']}:{source['commit']}" + docker_path = root / f"images-docker/{role}.docker.tar" + docker_summary = _write_docker_archive( + docker_path, + role, + spec["name"], + source["commit"], + source["tag"], + image_ref, + ) + assert docker_summary["config_digest"] == oci_summary["config_digest"] + assert docker_summary["rootfs_diff_ids"] == oci_summary["rootfs_diff_ids"] + assert ( + docker_summary["rootfs_identity_sha256"] + == oci_summary["rootfs_identity_sha256"] + ) + sbom_path = root / f"sbom/{role}.spdx.json" + _write(sbom_path, common.canonical_json_bytes(_spdx(role))) + sbom_summary = common.validate_spdx(_spdx(role), policy) + scan_path = root / f"scans/{role}.trivy.json" + scan_payload = _scan(role + ".docker.tar", image_id=oci_summary["config_digest"]) + _write(scan_path, common.canonical_json_bytes(scan_payload)) + scan_summary = common.validate_trivy_scan( + scan_payload, + policy, + expected_config_digest=oci_summary["config_digest"], + expected_artifact_basename=docker_path.name, + role=role, + at=datetime(2026, 9, 20, 0, 5, tzinfo=timezone.utc), + ) + images.append( + { + "sequence": sequence, + "role": role, + "image_name": spec["name"], + "environment_key": spec["environment_key"], + "image_ref": image_ref, + "dockerfile": spec["dockerfile"], + "base_image": spec["base_image"], + "platform": "linux/amd64", + "oci": common.file_record(oci_path, f"images/{role}.oci.tar", **oci_summary), + "docker_archive": common.file_record( + docker_path, + f"images-docker/{role}.docker.tar", + **docker_summary, + ), + "sbom": common.file_record(sbom_path, f"sbom/{role}.spdx.json", **sbom_summary), + "scan": common.file_record( + scan_path, + f"scans/{role}.trivy.json", + scanner="trivy", + scanner_version="0.74.0", + **scan_summary, + ), + } + ) + receipt_unsigned = { + "schema_version": common.BUILD_RECEIPT_SCHEMA, + "candidate": { + "commit": source["commit"], + "tag": source["tag"], + "tag_object_oid": source["tag_object_oid"], + "tree": source["tree"], + "commit_tree": common.validate_manifest_commit_tree( + source["repo"], commit=source["commit"], manifest=source["manifest"] + ), + "commit_timestamp": source["timestamp"], + "source_manifest_payload_sha256": source["manifest"]["manifest_payload_sha256"], + "source_manifest": common.file_record( + root / "source/source-manifest.json", "source/source-manifest.json" + ), + "source_archive": common.file_record( + root / "source/source-archive.tar", + "source/source-archive.tar", + root_prefix="", + file_count=len(source["manifest"]["files"]["items"]), + ), + }, + "policy": common.file_record( + root / "policy/linux-release-security-policy.v1.json", + "policy/linux-release-security-policy.v1.json", + schema_version=common.POLICY_SCHEMA, + target=common.TARGET, + ), + "builder": { + "os": "linux", + "architecture": "amd64", + "target_platform": "linux/amd64", + "tools": common.REQUIRED_TOOL_VERSIONS, + "command_timeouts_seconds": common.COMMAND_TIMEOUTS_SECONDS, + "controlled_environment": common.CONTROLLED_SUBPROCESS_ENV, + "docker_host": common.LOCAL_DOCKER_HOST, + "executables": { + name: { + "path": path, + "sha256": digit * 64, + "size_bytes": 1024, + "owner_uid": 0, + "mode": "0755", + } + for (name, path), digit in zip( + common.PINNED_EXECUTABLES.items(), ("1", "2", "3", "4"), strict=True + ) + }, + "buildx_builder": { + "name": "fixture-builder", + "driver": "docker-container", + "node": "fixture-builder0", + "node_status": "running", + "buildkit_version": "0.13.2", + "platforms": ["linux/amd64"], + "exporters": ["oci", "docker"], + "inspect_sha256": "b" * 64, + "container_name": "buildx_buildkit_fixture-builder0", + "driver_image": common.BUILDER_POLICY["required_driver_image"], + "driver_image_digest": common.BUILDER_POLICY["required_driver_image_digest"], + "local_driver_image_id": common.BUILDER_POLICY[ + "required_local_driver_image_id" + ], + "image_transport": "domestic_registry_mirror", + "container_inspect_sha256": "c" * 64, + "config_root": { + "path": common.CONTROLLED_SUBPROCESS_ENV["BUILDX_CONFIG"], + "selected_builder_instance": "instances/fixture-builder", + "entry_count": 3, + "metadata_sha256": "a" * 64, + "root_owned": True, + "group_or_other_writable": False, + "symlinks_allowed": False, + }, + }, + "base_image_transports": [ + { + "transport_reference": reference, + "content_digest": digest, + "media_type": "application/vnd.oci.image.manifest.v1+json", + "transport": "domestic_registry_mirror", + "inspect_sha256": "d" * 64, + } + for reference, digest in sorted( + { + item["base_image"]["transport_reference"]: item["base_image"][ + "content_digest" + ] + for item in common.REQUIRED_IMAGE_SPECS + }.items() + ) + ], + "candidate_checkout": common.validate_candidate_checkout( + source["repo"], commit=source["commit"] + ), + "release_controls": release_controls, + "skopeo_used": False, + }, + "build": { + "started_at": "2026-09-20T00:00:30Z", + "completed_at": "2026-09-20T00:10:00Z", + "source_date_epoch": source["epoch"], + "parallelism": 1, + "image_order": list(common.REQUIRED_ROLES), + "compose_gate": { + "synthetic_environment": True, + "production_secrets_used": False, + "execution_engine": "self_bound_release_runner_builtin_v1", + "validator": { + "source_path": common.COMPOSE_GATE_POLICY["validator"], + "sha256": source_records[common.COMPOSE_GATE_POLICY["validator"]][ + "sha256" + ], + "size_bytes": source_records[common.COMPOSE_GATE_POLICY["validator"]][ + "size" + ], + }, + "environment": common.file_record( + compose_env_path, "validation/synthetic-compose-gate.env" + ), + "report": common.file_record( + compose_report_path, + "validation/production-compose-gate.json", + **compose_summary, + ), + }, + }, + "security": { + "passed": True, + "fail_closed": True, + "thresholds": {"CRITICAL": 0, "HIGH": 0}, + "exemptions": policy["vulnerability_scan"]["exemptions"], + "vulnerability_counts": { + "total": {"CRITICAL": 0, "HIGH": 0}, + "exempted": {"CRITICAL": 0, "HIGH": 0}, + "blocking": {"CRITICAL": 0, "HIGH": 0}, + }, + "waiver_ids": [], + "database": common.file_record( + db_path, + "security/trivy-db-metadata.json", + repository_transport={ + "reference": common.TRIVY_DB_REPOSITORY_TRANSPORT["reference"], + "transport": common.TRIVY_DB_REPOSITORY_TRANSPORT["transport"], + "schema_version": common.TRIVY_DB_REPOSITORY_TRANSPORT["schema_version"], + "resolved_digest": "sha256:" + "e" * 64, + "media_type": common.TRIVY_DB_REPOSITORY_TRANSPORT[ + "allowed_media_types" + ][0], + "stable_during_download": True, + "before_download_inspect_sha256": "f" * 64, + "after_download_inspect_sha256": "1" * 64, + }, + **db_summary, + ), + }, + "images": images, + "claims": [], + } + receipt = common.with_payload_digest(receipt_unsigned, "receipt_payload_sha256") + receipt_path = root / "build-receipt.json" + receipt_path.write_bytes(common.canonical_json_bytes(receipt)) + return {**source, "root": root, "receipt": receipt, "receipt_path": receipt_path, "policy": policy} + + +def _rewrite_receipt(bundle: dict[str, Any]) -> None: + receipt = dict(bundle["receipt"]) + receipt.pop("receipt_payload_sha256", None) + receipt = common.with_payload_digest(receipt, "receipt_payload_sha256") + bundle["receipt"] = receipt + bundle["receipt_path"].write_bytes(common.canonical_json_bytes(receipt)) + + +def _assemble_bundle( + bundle: dict[str, Any], *, output: Path +) -> dict[str, Any]: + return assembler.assemble_installable_release( + artifact_root=bundle["root"], + receipt_path=bundle["receipt_path"], + expected_receipt_sha256=common.sha256_file(bundle["receipt_path"]), + repo=bundle["repo"], + output=output, + ) + + +def test_policy_pins_exact_linux_toolchain_and_zero_vulnerability_thresholds() -> None: + policy = common.load_policy(POLICY_PATH) + + assert policy["target"] == {"os": "linux", "architecture": "amd64", "platform": "linux/amd64"} + assert policy["builder"] == common.BUILDER_POLICY + assert policy["execution"]["command_timeouts_seconds"] == common.COMMAND_TIMEOUTS_SECONDS + assert { + name: value["allowed_versions"] for name, value in policy["toolchain"].items() + } == {name: [version] for name, version in common.REQUIRED_TOOL_VERSIONS.items()} + assert policy["vulnerability_scan"]["thresholds"] == {"CRITICAL": 0, "HIGH": 0} + assert policy["vulnerability_scan"]["ignore_unfixed"] is False + assert policy["vulnerability_scan"]["exemptions"] == common.VULNERABILITY_WAIVERS + waiver = policy["vulnerability_scan"]["exemptions"][0] + assert waiver["expires_at"] == "2026-10-20T00:00:00Z" + assert waiver["scope"]["roles"] == ["contract-api", "ai-platform-api", "ai-worker"] + assert sum(len(item["package_names"]) for item in waiver["findings"]) == 44 + assert ( + policy["vulnerability_scan"]["database"]["repository_transport"] + == common.TRIVY_DB_REPOSITORY_TRANSPORT + ) + assert policy["unsupported_tools"]["skopeo"].startswith("not_required") + + +def test_plan_mode_validates_commit_tag_manifest_archive_and_orders_four_images(tmp_path: Path) -> None: + source = _fixture_source(tmp_path) + + plan = builder.create_build_plan( + repo=source["repo"], + source_archive=source["archive_path"], + source_manifest=source["manifest_path"], + commit=source["commit"], + tag=source["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / "never-created", + ) + + assert plan["execution"] is False + assert plan["network_or_builder_tools_invoked"] is False + assert plan["parallelism"] == 1 + assert [item["role"] for item in plan["images"]] == list(common.REQUIRED_ROLES) + assert [item["sequence"] for item in plan["images"]] == [1, 2, 3, 4] + compose_commands = [ + command + for command in plan["pre_build_commands"] + if command[:4] + == [ + common.PINNED_EXECUTABLES["docker"], + "--host", + common.LOCAL_DOCKER_HOST, + "compose", + ] + ] + assert len(compose_commands) == 8 + assert all(command[-3:] == ["config", "--format", "json"] for command in compose_commands) + assert all(common.COMPOSE_GATE_POLICY["validator"] not in command for command in compose_commands) + assert plan["compose_gate"]["execution_engine"] == "self_bound_release_runner_builtin_v1" + assert not (tmp_path / "never-created").exists() + for image in plan["images"]: + flattened = " ".join(" ".join(command) for command in image["commands"]) + assert "linux/amd64" in flattened + assert "spdx-json" in flattened + assert "HIGH,CRITICAL" in flattened + assert "type=oci" in flattened and "type=docker" in flattened + assert "--builder fixture-builder" in flattened + assert common.TRIVY_DB_REPOSITORY_TRANSPORT["reference"] in flattened + trivy_command = image["commands"][2] + assert trivy_command[trivy_command.index("--input") + 1].endswith(".docker.tar") + + +def test_release_plan_rejects_git_replace_refs_even_when_checkout_is_clean( + tmp_path: Path, +) -> None: + source = _fixture_source(tmp_path) + replacement = _git( + source["repo"], + "commit-tree", + source["tree"], + "-p", + source["commit"], + "-m", + "forbidden replacement commit", + ) + _git(source["repo"], "replace", source["commit"], replacement) + + with pytest.raises(common.ReleaseValidationError, match="refs/replace"): + builder.create_build_plan( + repo=source["repo"], + source_archive=source["archive_path"], + source_manifest=source["manifest_path"], + commit=source["commit"], + tag=source["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / "out", + ) + + +def test_release_plan_requires_exact_clean_candidate_head(tmp_path: Path) -> None: + dirty = _fixture_source(tmp_path / "dirty") + _write(dirty["repo"] / "untracked.txt", b"not part of the candidate\n") + with pytest.raises(common.ReleaseValidationError, match="working tree is dirty"): + builder.create_build_plan( + repo=dirty["repo"], + source_archive=dirty["archive_path"], + source_manifest=dirty["manifest_path"], + commit=dirty["commit"], + tag=dirty["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / "dirty-output", + ) + + wrong_head = _fixture_source(tmp_path / "wrong-head") + _write(wrong_head["repo"] / "next.txt", b"later commit\n") + _git(wrong_head["repo"], "add", "next.txt") + _git( + wrong_head["repo"], + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "later", + ) + with pytest.raises(common.ReleaseValidationError, match="does not equal candidate commit"): + builder.create_build_plan( + repo=wrong_head["repo"], + source_archive=wrong_head["archive_path"], + source_manifest=wrong_head["manifest_path"], + commit=wrong_head["commit"], + tag=wrong_head["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / "wrong-head-output", + ) + + +def test_release_plan_binds_running_runner_common_and_policy_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = _fixture_source(tmp_path) + controls = ( + ("runner", builder, "__file__", Path(builder.__file__).read_bytes()), + ("common", builder.release_common, "__file__", Path(common.__file__).read_bytes()), + ) + for name, target, attribute, original in controls: + tampered = tmp_path / f"tampered-{name}.py" + tampered.write_bytes(original + b"\n# unapproved runtime bytes\n") + with monkeypatch.context() as context: + context.setattr(target, attribute, str(tampered)) + with pytest.raises( + common.ReleaseValidationError, + match="differs from candidate manifest/Git blob", + ): + builder.create_build_plan( + repo=source["repo"], + source_archive=source["archive_path"], + source_manifest=source["manifest_path"], + commit=source["commit"], + tag=source["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / f"{name}-output", + ) + + tampered_policy = tmp_path / "tampered-policy.json" + tampered_policy.write_bytes(POLICY_PATH.read_bytes() + b"\n") + with pytest.raises(common.ReleaseValidationError): + builder.create_build_plan( + repo=source["repo"], + source_archive=source["archive_path"], + source_manifest=source["manifest_path"], + commit=source["commit"], + tag=source["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / "policy-output", + policy_path=tampered_policy, + ) + + +def test_clean_clone_plan_subprocess_does_not_create_python_cache(tmp_path: Path) -> None: + source = _fixture_source(tmp_path / "origin") + clone = tmp_path / "clean-clone" + subprocess.run( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.eol=lf", + "clone", + "-q", + "--no-local", + str(source["repo"]), + str(clone), + ], + check=True, + ) + _git(clone, "config", "core.autocrlf", "false") + _git(clone, "config", "core.eol", "lf") + runner_path = clone / builder.RUNNER_SOURCE_RELATIVE + policy_path = clone / builder.POLICY_SOURCE_RELATIVE + environment = os.environ.copy() + environment.pop("PYTHONDONTWRITEBYTECODE", None) + completed = subprocess.run( + [ + sys.executable, + str(runner_path), + "--plan", + "--repo", + str(clone), + "--source-archive", + str(source["archive_path"]), + "--source-manifest", + str(source["manifest_path"]), + "--commit", + source["commit"], + "--tag", + source["tag"], + "--builder", + "fixture-builder", + "--output-dir", + str(tmp_path / "never-created"), + "--policy", + str(policy_path), + ], + cwd=clone, + check=False, + capture_output=True, + text=True, + env=environment, + timeout=60, + ) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout)["execution"] is False + assert _git_bytes(clone, "status", "--porcelain=v1", "-z", "-uall") == b"" + assert not list(clone.rglob("__pycache__")) + + +def test_plan_mode_rejects_tag_and_archive_drift(tmp_path: Path) -> None: + source = _fixture_source(tmp_path) + other = source["repo"] / "other.txt" + other.write_text("other\n", encoding="utf-8") + _git(source["repo"], "add", "other.txt") + _git(source["repo"], "-c", "commit.gpgsign=false", "commit", "-q", "-m", "other") + _git(source["repo"], "tag", "-a", "wrong-tag", "-m", "wrong annotated tag") + _git(source["repo"], "checkout", "--detach", "-q", source["commit"]) + + with pytest.raises(common.ReleaseValidationError, match="resolves to"): + builder.create_build_plan( + repo=source["repo"], + source_archive=source["archive_path"], + source_manifest=source["manifest_path"], + commit=source["commit"], + tag="wrong-tag", + builder_name="fixture-builder", + output_dir=tmp_path / "out", + ) + + archive_bytes = bytearray(source["archive_path"].read_bytes()) + archive_bytes[-1] ^= 1 + source["archive_path"].write_bytes(archive_bytes) + with pytest.raises(common.ReleaseValidationError): + builder.create_build_plan( + repo=source["repo"], + source_archive=source["archive_path"], + source_manifest=source["manifest_path"], + commit=source["commit"], + tag=source["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / "out", + ) + + +def test_plan_rejects_self_consistent_manifest_archive_not_equal_to_commit_tree( + tmp_path: Path, +) -> None: + source = _fixture_source(tmp_path) + forged_manifest = json.loads(json.dumps(source["manifest"])) + forged_manifest["files"]["items"] = [ + item + for item in forged_manifest["files"]["items"] + if item["path"] != "README.md" + ] + _reseal_source_manifest(forged_manifest) + forged_manifest_path = tmp_path / "forged-source-manifest.json" + forged_manifest_path.write_bytes(common.canonical_json_bytes(forged_manifest)) + forged_archive_path = tmp_path / "forged-source.tar" + subprocess.run( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.eol=lf", + "-C", + str(source["repo"]), + "archive", + "--format=tar", + f"--output={forged_archive_path}", + source["commit"], + *[item["path"] for item in forged_manifest["files"]["items"]], + ], + check=True, + ) + + # The forged pair is internally consistent; only the immutable commit-tree + # comparison exposes that a committed file was omitted. + common.verify_source_archive(forged_archive_path, forged_manifest) + with pytest.raises(common.ReleaseValidationError, match="not the candidate Git tree"): + builder.create_build_plan( + repo=source["repo"], + source_archive=forged_archive_path, + source_manifest=forged_manifest_path, + commit=source["commit"], + tag=source["tag"], + builder_name="fixture-builder", + output_dir=tmp_path / "out", + ) + + +def test_release_tag_must_be_annotated_and_tag_object_drift_is_rejected( + tmp_path: Path, +) -> None: + lightweight = _fixture_source(tmp_path / "lightweight") + lightweight_tag = "v2-fixture-lightweight" + _git(lightweight["repo"], "tag", lightweight_tag, lightweight["commit"]) + with pytest.raises(common.ReleaseValidationError, match="annotated tag object"): + common.validate_tag_binding( + lightweight["repo"], + tag=lightweight_tag, + commit=lightweight["commit"], + expected_tree=lightweight["tree"], + ) + + drift = _bundle(tmp_path / "drift") + original_tag_object = drift["tag_object_oid"] + _git(drift["repo"], "tag", "-d", drift["tag"]) + _git( + drift["repo"], + "tag", + "-a", + drift["tag"], + drift["commit"], + "-m", + "replacement annotated release tag", + ) + assert _git(drift["repo"], "rev-parse", f"refs/tags/{drift['tag']}") != original_tag_object + with pytest.raises(common.ReleaseValidationError, match="annotated tag object drift"): + _assemble_bundle( + drift, + output=drift["root"] / "installable-release-manifest.json", + ) + + +def test_source_archive_rejects_traversal_and_symlink_members(tmp_path: Path) -> None: + source = _fixture_source(tmp_path) + for member_name, member_type in (("../escape", tarfile.REGTYPE), ("README-link", tarfile.SYMTYPE)): + bad = tmp_path / f"bad-{member_type!r}.tar" + with tarfile.open(bad, "w") as archive: + info = tarfile.TarInfo(member_name) + info.type = member_type + info.mode = 0o664 + info.uid = info.gid = 0 + info.mtime = source["epoch"] + if member_type == tarfile.SYMTYPE: + info.linkname = "README.md" + else: + info.size = 0 + archive.addfile(info, BytesIO(b"") if member_type == tarfile.REGTYPE else None) + with pytest.raises(common.ReleaseValidationError): + common.verify_source_archive(bad, source["manifest"]) + + +def test_toolchain_parser_requires_exact_versions() -> None: + outputs = { + "docker": json.dumps({"Client": {"Version": "26.1.3"}, "Server": {"Version": "26.1.3"}}), + "buildx": "github.com/docker/buildx v0.14.0 abc", + "compose": "2.27.0\n", + "syft": json.dumps({"version": "1.52.0"}), + "trivy": json.dumps({"Version": "0.74.0"}), + } + assert builder.parse_tool_versions(outputs) == common.REQUIRED_TOOL_VERSIONS + outputs["trivy"] = json.dumps({"Version": "0.75.0"}) + with pytest.raises(common.ReleaseValidationError, match="version drift"): + builder.parse_tool_versions(outputs) + outputs["trivy"] = json.dumps({"Version": "0.74.0"}) + outputs["buildx"] = "github.com/docker/buildx v0.14.0-desktop.1 abc" + with pytest.raises(common.ReleaseValidationError, match="parse"): + builder.parse_tool_versions(outputs) + + +def test_external_command_timeout_is_fail_closed(monkeypatch: pytest.MonkeyPatch) -> None: + def time_out(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: + assert kwargs["timeout"] == 7 + raise subprocess.TimeoutExpired(args[0], 7) + + monkeypatch.setattr(builder.subprocess, "run", time_out) + with pytest.raises(common.ReleaseValidationError, match="timed out after 7 seconds"): + builder._run_command(["required-tool", "probe"], timeout_seconds=7) + + +def test_release_commands_ignore_ambient_path_and_docker_routing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(common.sys, "platform", "linux") + monkeypatch.setenv("PATH", str(tmp_path / "attacker-bin")) + monkeypatch.setenv("DOCKER_HOST", "tcp://attacker.invalid:2375") + monkeypatch.setenv("DOCKER_CONTEXT", "attacker-context") + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / "attacker-docker-config")) + captured: list[tuple[list[str], dict[str, Any]]] = [] + + def fake_run(arguments: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + captured.append((arguments, kwargs)) + return subprocess.CompletedProcess(arguments, 0, stdout="ok\n", stderr="") + + monkeypatch.setattr(builder.subprocess, "run", fake_run) + builder._run_command( + [ + common.PINNED_EXECUTABLES["docker"], + "--host", + common.LOCAL_DOCKER_HOST, + "version", + ] + ) + common._run_git(tmp_path, "rev-parse", "--verify", "HEAD") + + docker_command, docker_kwargs = captured[0] + git_command, git_kwargs = captured[1] + assert docker_command[:3] == [ + "/usr/bin/docker", + "--host", + "unix:///var/run/docker.sock", + ] + assert git_command[0] == "/usr/bin/git" + assert git_command[1] == "--no-replace-objects" + assert docker_kwargs["env"] == common.CONTROLLED_SUBPROCESS_ENV + expected_git_environment = { + **common.CONTROLLED_SUBPROCESS_ENV, + **common.GIT_VALIDATION_ENV, + } + assert git_kwargs["env"] == expected_git_environment + for child_env in (docker_kwargs["env"], git_kwargs["env"]): + assert child_env["PATH"] == "/usr/bin:/bin" + assert child_env["DOCKER_CONFIG"] == "/var/empty/docker" + assert child_env["BUILDX_CONFIG"] == "/root/.docker/buildx" + assert "DOCKER_HOST" not in child_env + assert "DOCKER_CONTEXT" not in child_env + + +def test_execute_build_invokes_compose_gate_before_any_image_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = _fixture_source(tmp_path) + gate_calls: list[dict[str, Any]] = [] + + monkeypatch.setattr(builder, "_assert_real_builder_platform", lambda: None) + monkeypatch.setattr(builder, "validate_pinned_executables", lambda: {}) + monkeypatch.setattr( + builder, "validate_toolchain", lambda runner: dict(common.REQUIRED_TOOL_VERSIONS) + ) + monkeypatch.setattr( + builder, + "validate_buildx_builder", + lambda builder_name, runner: {"name": builder_name}, + ) + monkeypatch.setattr( + builder, "validate_base_image_transports", lambda policy, runner: [] + ) + + def gate_sentinel(**kwargs: Any) -> dict[str, Any]: + gate_calls.append(kwargs) + raise common.ReleaseValidationError("compose gate execution sentinel") + + monkeypatch.setattr(builder, "run_production_compose_gate", gate_sentinel) + + def unexpected_external_command(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: + raise AssertionError("image/DB command ran before the Compose gate") + + output = tmp_path / "build-output" + with pytest.raises(common.ReleaseValidationError, match="compose gate execution sentinel"): + builder.execute_build( + repo=source["repo"], + source_archive=source["archive_path"], + source_manifest=source["manifest_path"], + commit=source["commit"], + tag=source["tag"], + builder_name="fixture-builder", + output_dir=output, + runner=unexpected_external_command, + ) + + assert len(gate_calls) == 1 + assert gate_calls[0]["manifest"]["manifest_payload_sha256"] == source["manifest"][ + "manifest_payload_sha256" + ] + assert not output.exists() + assert not list(tmp_path.glob(".linux-release-partial-*")) + + +def test_compose_gate_binds_synthetic_env_and_all_candidate_inputs(tmp_path: Path) -> None: + source = _fixture_source(tmp_path) + source_root = tmp_path / "source-root" + common.verify_source_archive(source["archive_path"], source["manifest"], extract_to=source_root) + staging = tmp_path / "staging" + staging.mkdir() + env_bytes = common.synthetic_compose_gate_env_bytes() + calls: list[tuple[list[str], dict[str, Any]]] = [] + expected_cases = [ + (profile_name, profiles, mode) + for profile_name, profiles in builder.TRUSTED_PROFILE_CASES + for mode in ("online", "offline") + ] + + def fake_runner( + arguments: list[str], **kwargs: Any + ) -> subprocess.CompletedProcess[str]: + profile_name, profiles, mode = expected_cases[len(calls)] + calls.append((arguments, kwargs)) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=json.dumps( + _compose_model( + mode=mode, + profile_name=profile_name, + profiles=profiles, + ), + sort_keys=True, + ), + stderr="", + ) + + receipt = builder.run_production_compose_gate( + source_root=source_root, + staging=staging, + manifest=source["manifest"], + policy=common.load_policy(POLICY_PATH), + runner=fake_runner, + ) + + assert len(calls) == 8 + for index, ((arguments, kwargs), (profile_name, profiles, mode)) in enumerate( + zip(calls, expected_cases, strict=True) + ): + assert arguments[:4] == [ + common.PINNED_EXECUTABLES["docker"], + "--host", + common.LOCAL_DOCKER_HOST, + "compose", + ] + assert arguments[-3:] == ["config", "--format", "json"] + assert str( + source_root.joinpath(*common.COMPOSE_GATE_POLICY["validator"].split("/")) + ) not in arguments + assert arguments.count("-f") == (3 if mode == "offline" else 2) + assert [ + arguments[position + 1] + for position, token in enumerate(arguments) + if token == "--profile" + ] == list(profiles) + assert kwargs["timeout_seconds"] == common.COMMAND_TIMEOUTS_SECONDS[ + "compose_gate" + ] + assert "DOCKER_HOST" not in kwargs["env"] + assert receipt["synthetic_environment"] is True + assert receipt["production_secrets_used"] is False + assert receipt["execution_engine"] == "self_bound_release_runner_builtin_v1" + assert receipt["environment"]["sha256"] == hashlib.sha256(env_bytes).hexdigest() + assert receipt["report"]["case_count"] == 8 + report_payload = common.load_json_file( + staging / "validation/production-compose-gate.json", + label="test Compose gate report", + ) + assert receipt["report"]["compose_inputs"] == report_payload["compose_inputs"] + assert [case["profile_case"] for case in report_payload["cases"]] == [ + case[0] for case in expected_cases + ] + + +def test_compose_gate_never_executes_or_trusts_candidate_validator_stdout( + tmp_path: Path, +) -> None: + source = _fixture_source(tmp_path) + source_root = tmp_path / "source-root" + common.verify_source_archive( + source["archive_path"], source["manifest"], extract_to=source_root + ) + staging = tmp_path / "staging" + staging.mkdir() + _env_bytes, forged_self_attestation = _compose_gate_report(source) + commands: list[list[str]] = [] + + def fake_runner( + arguments: list[str], **kwargs: Any + ) -> subprocess.CompletedProcess[str]: + commands.append(arguments) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=json.dumps(forged_self_attestation, sort_keys=True), + stderr="", + ) + + with pytest.raises(common.ReleaseValidationError, match="no services object"): + builder.run_production_compose_gate( + source_root=source_root, + staging=staging, + manifest=source["manifest"], + policy=common.load_policy(POLICY_PATH), + runner=fake_runner, + ) + assert len(commands) == 1 + assert commands[0][0] == common.PINNED_EXECUTABLES["docker"] + assert str( + source_root.joinpath(*common.COMPOSE_GATE_POLICY["validator"].split("/")) + ) not in commands[0] + + +def test_explicit_buildx_builder_must_be_single_node_docker_container() -> None: + valid = """Name: release-builder +Driver: docker-container +Nodes: +Name: release-builder0 +Endpoint: npipe:////./pipe/docker_engine +Status: running +BuildKit version: v0.13.2 +Platforms: linux/amd64*, linux/amd64/v2 +""" + parsed = builder.parse_builder_inspect(valid, builder_name="release-builder") + assert parsed["driver"] == "docker-container" + assert parsed["exporters"] == ["oci", "docker"] + assert parsed["platforms"][0] == "linux/amd64" + + with pytest.raises(common.ReleaseValidationError, match="driver"): + builder.parse_builder_inspect( + valid.replace("docker-container", "docker"), builder_name="release-builder" + ) + + container = json.dumps( + [ + { + "Name": "/buildx_buildkit_release-builder0", + "Image": common.BUILDER_POLICY["required_local_driver_image_id"], + "Config": {"Image": common.BUILDER_POLICY["required_driver_image"]}, + "State": {"Running": True}, + } + ] + ) + container_summary = builder.parse_builder_container_inspect( + container, node_name="release-builder0" + ) + assert container_summary["driver_image_digest"] == common.BUILDER_POLICY[ + "required_driver_image_digest" + ] + + mirror = """Name: docker.m.daocloud.io/library/python@sha256:44ff +MediaType: application/vnd.oci.image.manifest.v1+json +Digest: sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b +""" + mirror_summary = builder.parse_imagetools_inspect( + mirror, + transport_reference=common.PYTHON_BASE["transport_reference"], + content_digest=common.PYTHON_BASE["content_digest"], + ) + assert mirror_summary["transport"] == "domestic_registry_mirror" + + calls: list[tuple[list[str], dict[str, Any]]] = [] + + def fake_runner( + arguments: list[str], **kwargs: Any + ) -> subprocess.CompletedProcess[str]: + calls.append((arguments, kwargs)) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=( + "Name: docker.m.daocloud.io/aquasec/trivy-db:2\n" + "MediaType: application/vnd.oci.image.manifest.v1+json\n" + "Digest: sha256:74d605ff5a02adc06ab748e6c07082fbb9880b436c9c3cb8228d12738b90d059\n" + ), + stderr="", + ) + + db_transport = builder.inspect_trivy_db_repository_transport( + common.load_policy(POLICY_PATH), fake_runner + ) + assert db_transport["resolved_digest"] == ( + "sha256:74d605ff5a02adc06ab748e6c07082fbb9880b436c9c3cb8228d12738b90d059" + ) + assert calls[0][1]["timeout_seconds"] == common.COMMAND_TIMEOUTS_SECONDS[ + "trivy_db_inspect" + ] + + +@pytest.mark.parametrize("failure", ["expired", "old", "malformed", "missing-version"]) +def test_trivy_database_fail_closed(failure: str) -> None: + policy = common.load_policy(POLICY_PATH) + payload: Any = { + "Version": "0.74.0", + "VulnerabilityDB": { + "Version": 2, + "UpdatedAt": "2026-09-20T00:00:00Z", + "NextUpdate": "2026-09-20T06:00:00Z", + "DownloadedAt": "2026-09-20T00:01:00Z", + }, + } + at = datetime(2026, 9, 20, 1, tzinfo=timezone.utc) + if failure == "expired": + payload["VulnerabilityDB"]["NextUpdate"] = "2026-09-20T00:30:00Z" + elif failure == "old": + at = datetime(2026, 9, 22, 1, tzinfo=timezone.utc) + elif failure == "malformed": + payload["VulnerabilityDB"]["UpdatedAt"] = "not-a-date" + else: + del payload["VulnerabilityDB"]["Version"] + with pytest.raises(common.ReleaseValidationError): + common.validate_trivy_db_metadata(payload, policy, at=at) + + +def test_trivy_scan_fails_closed_on_parse_error_or_high_critical() -> None: + policy = common.load_policy(POLICY_PATH) + with pytest.raises(common.ReleaseValidationError): + common.load_json_bytes(b'{"Results":', label="Trivy report") + for severity in ("HIGH", "CRITICAL"): + report = _scan( + "contract-api", + [{"VulnerabilityID": f"CVE-{severity}", "Severity": severity}], + ) + with pytest.raises(common.ReleaseValidationError, match="gate failed"): + common.validate_trivy_scan(report, policy) + + +def test_trivy_scan_allows_only_exact_unfixed_python_waiver_occurrences() -> None: + policy = common.load_policy(POLICY_PATH) + vulnerabilities = _waived_python_vulnerabilities() + assert len(vulnerabilities) == 44 + report = _scan("contract-api.docker.tar", vulnerabilities) + + summary = common.validate_trivy_scan( + report, + policy, + role="contract-api", + at=datetime(2026, 9, 20, tzinfo=timezone.utc), + ) + + waiver_id = common.VULNERABILITY_WAIVERS[0]["id"] + assert summary["counts"] == { + "total": {"CRITICAL": 0, "HIGH": 44}, + "exempted": {"CRITICAL": 0, "HIGH": 44}, + "blocking": {"CRITICAL": 0, "HIGH": 0}, + } + assert summary["waiver_ids"] == [waiver_id] + assert summary["waiver_exempted_counts"] == {waiver_id: 44} + + +@pytest.mark.parametrize( + ("mutation", "role", "at"), + [ + ("unknown-cve", "contract-api", datetime(2026, 9, 20, tzinfo=timezone.utc)), + ("wrong-package", "contract-api", datetime(2026, 9, 20, tzinfo=timezone.utc)), + ("wrong-installed", "contract-api", datetime(2026, 9, 20, tzinfo=timezone.utc)), + ("fixable", "contract-api", datetime(2026, 9, 20, tzinfo=timezone.utc)), + ("critical", "contract-api", datetime(2026, 9, 20, tzinfo=timezone.utc)), + ("duplicate", "contract-api", datetime(2026, 9, 20, tzinfo=timezone.utc)), + ("valid", "contract-web", datetime(2026, 9, 20, tzinfo=timezone.utc)), + ("valid", "contract-api", datetime(2026, 10, 20, tzinfo=timezone.utc)), + ], +) +def test_trivy_waiver_mismatch_or_expiry_fails_closed( + mutation: str, role: str, at: datetime +) -> None: + policy = common.load_policy(POLICY_PATH) + vulnerabilities = _waived_python_vulnerabilities() + if mutation == "unknown-cve": + vulnerabilities[0]["VulnerabilityID"] = "CVE-2099-0001" + elif mutation == "wrong-package": + vulnerabilities[0]["PkgName"] = "unexpected-package" + elif mutation == "wrong-installed": + vulnerabilities[0]["InstalledVersion"] += ".drift" + elif mutation == "fixable": + vulnerabilities[0]["FixedVersion"] = "9999" + elif mutation == "critical": + vulnerabilities[0]["Severity"] = "CRITICAL" + elif mutation == "duplicate": + vulnerabilities.append(dict(vulnerabilities[0])) + report = _scan("contract-api.docker.tar", vulnerabilities) + + with pytest.raises(common.ReleaseValidationError, match="gate failed"): + common.validate_trivy_scan(report, policy, role=role, at=at) + + +def test_docker_archive_rejects_layer_content_not_bound_to_config(tmp_path: Path) -> None: + path = tmp_path / "image.docker.tar" + role = "contract-api" + image_name = "yuqei/contract-api" + commit = "a" * 40 + tag = "v2-test" + image_ref = f"{image_name}:{commit}" + config = _image_config(role, image_name, commit, tag) + config_digest = hashlib.sha256(config).hexdigest() + layer_name = f"layers/{role}/layer.tar" + manifest = json.dumps( + [ + { + "Config": f"{config_digest}.json", + "RepoTags": [image_ref], + "Layers": [layer_name], + } + ], + sort_keys=True, + separators=(",", ":"), + ).encode() + entries = { + "manifest.json": manifest, + f"{config_digest}.json": config, + layer_name: b"tampered-layer", + } + with tarfile.open(path, "w") as archive: + for name, data in sorted(entries.items()): + info = tarfile.TarInfo(name) + info.mode = 0o644 + info.size = len(data) + archive.addfile(info, BytesIO(data)) + + with pytest.raises(common.ReleaseValidationError, match="rootfs.diff_ids"): + common.inspect_docker_archive(path, expected_repo_tag=image_ref) + + +def test_oci_archive_rejects_descriptor_valid_layer_not_bound_to_config_diff_id( + tmp_path: Path, +) -> None: + path = tmp_path / "malicious.oci.tar" + role = "contract-api" + image_name = "yuqei/contract-api" + commit = "a" * 40 + tag = "v2-test" + config = _image_config(role, image_name, commit, tag) + config_digest = hashlib.sha256(config).hexdigest() + malicious_layer = b"descriptor-valid-but-not-the-configured-rootfs" + layer_digest = hashlib.sha256(malicious_layer).hexdigest() + manifest_payload = { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": f"sha256:{config_digest}", + "size": len(config), + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": f"sha256:{layer_digest}", + "size": len(malicious_layer), + } + ], + } + manifest_bytes = json.dumps( + manifest_payload, sort_keys=True, separators=(",", ":") + ).encode() + manifest_digest = hashlib.sha256(manifest_bytes).hexdigest() + index_bytes = json.dumps( + { + "schemaVersion": 2, + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": f"sha256:{manifest_digest}", + "size": len(manifest_bytes), + "platform": {"os": "linux", "architecture": "amd64"}, + } + ], + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + entries = { + "oci-layout": b'{"imageLayoutVersion":"1.0.0"}', + "index.json": index_bytes, + f"blobs/sha256/{config_digest}": config, + f"blobs/sha256/{layer_digest}": malicious_layer, + f"blobs/sha256/{manifest_digest}": manifest_bytes, + } + with tarfile.open(path, "w") as archive: + for name, data in sorted(entries.items()): + info = tarfile.TarInfo(name) + info.mode = 0o644 + info.size = len(data) + archive.addfile(info, BytesIO(data)) + + with pytest.raises(common.ReleaseValidationError, match="rootfs.diff_ids"): + common.inspect_oci_archive(path) + + +def test_docker_archive_accepts_digest_named_gzip_blobs(tmp_path: Path) -> None: + path = tmp_path / "image-blobs.docker.tar" + role = "contract-api" + image_name = "yuqei/contract-api" + commit = "a" * 40 + tag = "v2-test" + image_ref = f"{image_name}:{commit}" + config = _image_config(role, image_name, commit, tag) + config_digest = hashlib.sha256(config).hexdigest() + compressed_layer = gzip.compress(_image_layer(role), mtime=0) + layer_digest = hashlib.sha256(compressed_layer).hexdigest() + manifest = json.dumps( + [ + { + "Config": f"blobs/sha256/{config_digest}", + "RepoTags": [image_ref], + "Layers": [f"blobs/sha256/{layer_digest}"], + } + ], + sort_keys=True, + separators=(",", ":"), + ).encode() + entries = { + "manifest.json": manifest, + f"blobs/sha256/{config_digest}": config, + f"blobs/sha256/{layer_digest}": compressed_layer, + } + with tarfile.open(path, "w") as archive: + for name, data in sorted(entries.items()): + info = tarfile.TarInfo(name) + info.mode = 0o644 + info.size = len(data) + archive.addfile(info, BytesIO(data)) + + summary = common.inspect_docker_archive(path, expected_repo_tag=image_ref) + assert summary["config_digest"] == f"sha256:{config_digest}" + + +def test_installable_manifest_revalidates_complete_bundle(tmp_path: Path) -> None: + bundle = _bundle(tmp_path) + output = bundle["root"] / "installable-release-manifest.json" + + manifest = _assemble_bundle(bundle, output=output) + + assert manifest["candidate"]["commit"] == bundle["commit"] + assert manifest["security_gate"]["passed"] is True + assert [image["role"] for image in manifest["images"]] == list(common.REQUIRED_ROLES) + assert len(manifest["docker_import_plan"]["images"]) == 4 + assert all( + item["command_argv"][:5] + == [ + common.PINNED_EXECUTABLES["docker"], + "--host", + common.LOCAL_DOCKER_HOST, + "image", + "load", + ] + for item in manifest["docker_import_plan"]["images"] + ) + assert all( + item["offline_compose_override"] == item["expected_loaded_image_id"] + and item["repo_digests_expected_after_load"] == [] + for item in manifest["docker_import_plan"]["images"] + ) + assert output.read_bytes() == common.canonical_json_bytes(manifest) + + +def test_installable_manifest_preserves_complete_waiver_accounting(tmp_path: Path) -> None: + bundle = _bundle(tmp_path) + image = next( + item for item in bundle["receipt"]["images"] if item["role"] == "contract-api" + ) + scan_path = bundle["root"] / image["scan"]["path"] + scan_payload = _scan( + Path(image["docker_archive"]["path"]).name, + _waived_python_vulnerabilities(), + image_id=image["oci"]["config_digest"], + ) + scan_path.write_bytes(common.canonical_json_bytes(scan_payload)) + scan_summary = common.validate_trivy_scan( + scan_payload, + bundle["policy"], + expected_config_digest=image["oci"]["config_digest"], + expected_artifact_basename=Path(image["docker_archive"]["path"]).name, + role="contract-api", + at=datetime(2026, 9, 20, 0, 5, tzinfo=timezone.utc), + ) + image["scan"] = common.file_record( + scan_path, + image["scan"]["path"], + scanner="trivy", + scanner_version="0.74.0", + **scan_summary, + ) + bundle["receipt"]["security"]["vulnerability_counts"] = scan_summary["counts"] + bundle["receipt"]["security"]["waiver_ids"] = scan_summary["waiver_ids"] + _rewrite_receipt(bundle) + + manifest = _assemble_bundle( + bundle, + output=bundle["root"] / "installable-release-manifest.json", + ) + + assert manifest["security_gate"]["vulnerability_counts"] == scan_summary["counts"] + assert manifest["security_gate"]["waiver_ids"] == scan_summary["waiver_ids"] + assert manifest["security_gate"]["vulnerability_counts"]["blocking"]["HIGH"] == 0 + + +def test_installable_manifest_rejects_missing_and_duplicate_roles(tmp_path: Path) -> None: + missing = _bundle(tmp_path / "missing") + missing["receipt"]["images"].pop() + _rewrite_receipt(missing) + with pytest.raises(common.ReleaseValidationError): + _assemble_bundle( + missing, + output=missing["root"] / "installable-release-manifest.json", + ) + + duplicate = _bundle(tmp_path / "duplicate") + duplicate["receipt"]["images"][-1]["role"] = duplicate["receipt"]["images"][0]["role"] + _rewrite_receipt(duplicate) + with pytest.raises(common.ReleaseValidationError, match="duplicate image role"): + _assemble_bundle( + duplicate, + output=duplicate["root"] / "installable-release-manifest.json", + ) + + +def test_installable_manifest_rejects_duplicate_artifact_path_or_image_digest( + tmp_path: Path, +) -> None: + duplicate_path = _bundle(tmp_path / "path") + duplicate_path["receipt"]["images"][1]["oci"] = dict( + duplicate_path["receipt"]["images"][0]["oci"] + ) + _rewrite_receipt(duplicate_path) + with pytest.raises(common.ReleaseValidationError, match="duplicate|colliding"): + _assemble_bundle( + duplicate_path, + output=duplicate_path["root"] / "installable-release-manifest.json", + ) + + duplicate_digest = _bundle(tmp_path / "digest") + first = duplicate_digest["receipt"]["images"][0]["oci"] + second = duplicate_digest["receipt"]["images"][1]["oci"] + first_path = duplicate_digest["root"] / first["path"] + second_path = duplicate_digest["root"] / second["path"] + shutil.copyfile(first_path, second_path) + second.update({key: value for key, value in first.items() if key != "path"}) + _rewrite_receipt(duplicate_digest) + with pytest.raises(common.ReleaseValidationError, match="duplicate OCI manifest digest|role labels"): + _assemble_bundle( + duplicate_digest, + output=duplicate_digest["root"] / "installable-release-manifest.json", + ) + + +def test_installable_manifest_rejects_digest_drift_and_traversal(tmp_path: Path) -> None: + drift = _bundle(tmp_path / "drift") + drift_path = drift["root"] / drift["receipt"]["images"][0]["oci"]["path"] + drift_path.write_bytes(drift_path.read_bytes() + b"tamper") + with pytest.raises(common.ReleaseValidationError, match="drift"): + _assemble_bundle( + drift, + output=drift["root"] / "installable-release-manifest.json", + ) + + traversal = _bundle(tmp_path / "traversal") + traversal["receipt"]["images"][0]["oci"]["path"] = "../escape.oci.tar" + _rewrite_receipt(traversal) + with pytest.raises(common.ReleaseValidationError, match="traversal|relative|forbidden"): + _assemble_bundle( + traversal, + output=traversal["root"] / "installable-release-manifest.json", + ) + + +def test_installable_manifest_rejects_symlinked_artifact( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bundle = _bundle(tmp_path) + target = bundle["root"] / bundle["receipt"]["images"][0]["scan"]["path"] + original = Path.is_symlink + + def reports_target_as_symlink(path: Path) -> bool: + return path == target or original(path) + + monkeypatch.setattr(Path, "is_symlink", reports_target_as_symlink) + with pytest.raises(common.ReleaseValidationError, match="symlink"): + _assemble_bundle( + bundle, + output=bundle["root"] / "installable-release-manifest.json", + ) + + +def test_installable_manifest_rejects_internally_consistent_failed_scan(tmp_path: Path) -> None: + bundle = _bundle(tmp_path) + image = bundle["receipt"]["images"][0] + scan_path = bundle["root"] / image["scan"]["path"] + failed = _scan( + Path(image["docker_archive"]["path"]).name, + [{"VulnerabilityID": "CVE-2099-0001", "Severity": "HIGH"}], + image_id=image["oci"]["config_digest"], + ) + scan_path.write_bytes(common.canonical_json_bytes(failed)) + image["scan"]["sha256"] = common.sha256_file(scan_path) + image["scan"]["size_bytes"] = scan_path.stat().st_size + image["scan"]["counts"] = { + "total": {"CRITICAL": 0, "HIGH": 1}, + "exempted": {"CRITICAL": 0, "HIGH": 0}, + "blocking": {"CRITICAL": 0, "HIGH": 1}, + } + image["scan"]["passed"] = False + _rewrite_receipt(bundle) + + with pytest.raises(common.ReleaseValidationError, match="gate failed"): + _assemble_bundle( + bundle, + output=bundle["root"] / "installable-release-manifest.json", + ) diff --git a/yuqei-infra/tests/test_production_compose_merge_gate.py b/yuqei-infra/tests/test_production_compose_merge_gate.py new file mode 100644 index 00000000..d0f94977 --- /dev/null +++ b/yuqei-infra/tests/test_production_compose_merge_gate.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_script(): + path = REPO_ROOT / "yuqei-infra/deploy/verify-production-compose.py" + spec = importlib.util.spec_from_file_location("verify_production_compose", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +gate = _load_script() + + +@dataclass +class GateFixture: + repo: Path + env_file: Path + images: dict[str, str] + base: Path + production: Path + offline: Path + + +def _image(name: str, digit: str) -> str: + return f"registry.example.test/yuqei/{name}@sha256:{digit * 64}" + + +def _make_fixture(tmp_path: Path) -> GateFixture: + repo = tmp_path / "repo" + base = repo / gate.BASE_COMPOSE_RELATIVE + production = repo / gate.PRODUCTION_COMPOSE_RELATIVE + offline = repo / gate.OFFLINE_COMPOSE_RELATIVE + base.parent.mkdir(parents=True) + base.write_text("services: {}\n", encoding="utf-8", newline="\n") + production.write_text("services: {}\n", encoding="utf-8", newline="\n") + offline.write_text("services: {}\n", encoding="utf-8", newline="\n") + images = { + "CONTRACT_API_IMAGE": _image("contract-api", "1"), + "CONTRACT_WEB_IMAGE": _image("contract-web", "2"), + "AI_PLATFORM_API_IMAGE": _image("ai-platform-api", "3"), + "AI_WORKER_IMAGE": _image("ai-worker", "4"), + } + env_file = tmp_path / "strict-production.env" + env_file.write_text( + "COMPOSE_PROJECT_NAME=yuqei-prod\n" + + "".join(f"{key}={value}\n" for key, value in images.items()), + encoding="utf-8", + newline="\n", + ) + return GateFixture(repo, env_file, images, base, production, offline) + + +def _profiles(command: list[str]) -> set[str]: + return { + command[index + 1] + for index, token in enumerate(command[:-1]) + if token == "--profile" + } + + +def _compose_files(command: list[str]) -> list[str]: + return [ + command[index + 1] + for index, token in enumerate(command[:-1]) + if token == "-f" + ] + + +class FakeComposeRunner: + def __init__( + self, + fixture: GateFixture, + mutate: Callable[[dict[str, Any], bool, set[str], list[str]], None] | None = None, + ) -> None: + self.fixture = fixture + self.mutate = mutate + self.calls: list[tuple[list[str], dict[str, Any]]] = [] + + def __call__(self, command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + self.calls.append((list(command), kwargs)) + profiles = _profiles(command) + offline = str(self.fixture.offline.resolve()) in _compose_files(command) + services: dict[str, dict[str, Any]] = {} + for service_name in gate.PRODUCTION_SERVICES: + required_profile = gate.PROFILE_ONLY_SERVICES.get(service_name) + if required_profile is not None and required_profile not in profiles: + continue + service: dict[str, Any] = { + "image": f"registry.example.test/infra/{service_name}@sha256:{'e' * 64}" + } + image_env = gate.APPLICATION_SERVICE_IMAGE_ENV.get(service_name) + if image_env is not None: + service["image"] = self.fixture.images[image_env] + service["pull_policy"] = "never" if offline else "always" + elif offline: + service["pull_policy"] = "never" + services[service_name] = service + if self.mutate is not None: + self.mutate(services, offline, profiles, command) + stdout = json.dumps({"name": "yuqei-prod", "services": services}) + return subprocess.CompletedProcess(command, 0, stdout=stdout, stderr="") + + +def test_gate_renders_online_and_offline_for_all_profile_combinations(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + runner = FakeComposeRunner(fixture) + + report = gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=runner, + ) + + assert report["status"] == "passed" + assert report["application_service_count"] == 9 + assert report["production_service_count"] == 16 + assert len(report["cases"]) == 8 + assert [(case["profile_case"], case["mode"]) for case in report["cases"]] == [ + ("default", "online"), + ("default", "offline"), + ("backend-hosted-web", "online"), + ("backend-hosted-web", "offline"), + ("ops", "online"), + ("ops", "offline"), + ("backend-hosted-web+ops", "online"), + ("backend-hosted-web+ops", "offline"), + ] + assert [case["service_count"] for case in report["cases"]] == [ + 13, + 13, + 14, + 14, + 15, + 15, + 16, + 16, + ] + + assert len(runner.calls) == 8 + for command, kwargs in runner.calls: + assert command[:4] == [ + gate.DOCKER_CLI, + "--host", + gate.LOCAL_DOCKER_HOST, + "compose", + ] + files = _compose_files(command) + assert files[:2] == [str(fixture.base.resolve()), str(fixture.production.resolve())] + if str(fixture.offline.resolve()) in files: + assert files == [ + str(fixture.base.resolve()), + str(fixture.production.resolve()), + str(fixture.offline.resolve()), + ] + else: + assert files == [str(fixture.base.resolve()), str(fixture.production.resolve())] + assert command[-3:] == ["config", "--format", "json"] + assert all(token not in command for token in ("up", "pull", "build", "down", "run")) + assert kwargs["shell"] is False + assert kwargs["check"] is False + assert kwargs["cwd"] == str(fixture.repo.resolve()) + assert kwargs["env"] == { + "HOME": "/var/empty", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + } + + assert report["env_file_sha256"] == hashlib.sha256(fixture.env_file.read_bytes()).hexdigest() + assert [item["path"] for item in report["compose_inputs"]] == [ + gate.BASE_COMPOSE_RELATIVE, + gate.PRODUCTION_COMPOSE_RELATIVE, + gate.OFFLINE_COMPOSE_RELATIVE, + ] + assert [item["sha256"] for item in report["compose_inputs"]] == [ + hashlib.sha256(fixture.base.read_bytes()).hexdigest(), + hashlib.sha256(fixture.production.read_bytes()).hexdigest(), + hashlib.sha256(fixture.offline.read_bytes()).hexdigest(), + ] + + +def test_hardcoded_latest_image_mutation_fails_closed(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + + def mutate(services, _offline, _profiles, _command): + services["contract-api"]["image"] = "registry.example.test/yuqei/contract-api:latest" + + runner = FakeComposeRunner(fixture, mutate) + with pytest.raises(gate.ComposeGateError, match="exactly match CONTRACT_API_IMAGE"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=runner, + ) + assert len(runner.calls) == 1 + + +def test_application_build_mutation_fails_closed(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + + def mutate(services, _offline, _profiles, _command): + services["worker"]["build"] = {"context": "../.."} + + with pytest.raises(gate.ComposeGateError, match="retains a build definition"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=FakeComposeRunner(fixture, mutate), + ) + + +def test_online_pull_policy_mutation_fails_closed(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + + def mutate(services, offline, _profiles, _command): + if not offline: + services["ai-platform-api"]["pull_policy"] = "missing" + + with pytest.raises(gate.ComposeGateError, match="pull_policy must be always"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=FakeComposeRunner(fixture, mutate), + ) + + +def test_offline_pull_policy_and_build_are_checked_for_infrastructure(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + + def mutate(services, offline, _profiles, _command): + if offline: + services["edge"]["pull_policy"] = "always" + services["postgres"]["build"] = "." + + with pytest.raises(gate.ComposeGateError, match="service edge pull_policy must be never"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=FakeComposeRunner(fixture, mutate), + ) + + +def test_both_profiles_require_all_sixteen_services(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + + def mutate(services, _offline, profiles, _command): + if profiles == {"backend-hosted-web", "ops"}: + services.pop("minio-backup") + + runner = FakeComposeRunner(fixture, mutate) + with pytest.raises(gate.ComposeGateError, match="required services are missing: minio-backup"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=runner, + ) + assert len(runner.calls) == 7 + + +@pytest.mark.parametrize( + ("line", "message"), + [ + ("COMPOSE_PROJECT_NAME=prod;touch-pwned", "missing or unsafe"), + ("CONTRACT_API_IMAGE=registry.example.test/yuqei/contract-api:latest", "immutable"), + ], +) +def test_strict_env_rejects_command_injection_and_mutable_images( + tmp_path: Path, line: str, message: str +) -> None: + fixture = _make_fixture(tmp_path) + lines = fixture.env_file.read_text(encoding="utf-8").splitlines() + key = line.split("=", 1)[0] + fixture.env_file.write_text( + "\n".join(line if item.startswith(key + "=") else item for item in lines) + "\n", + encoding="utf-8", + newline="\n", + ) + runner = FakeComposeRunner(fixture) + + with pytest.raises(gate.ComposeGateError, match=message): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=runner, + ) + assert runner.calls == [] + + +def test_duplicate_env_key_fails_before_runner(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + with fixture.env_file.open("a", encoding="utf-8", newline="\n") as handle: + handle.write("CONTRACT_WEB_IMAGE=" + fixture.images["CONTRACT_WEB_IMAGE"] + "\n") + runner = FakeComposeRunner(fixture) + + with pytest.raises(gate.ComposeGateError, match="duplicate strict env key"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=runner, + ) + assert runner.calls == [] + + +def test_missing_offline_override_fails_before_runner(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + fixture.offline.unlink() + runner = FakeComposeRunner(fixture) + + with pytest.raises(gate.ComposeGateError, match="offline Compose override is missing"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=runner, + ) + assert runner.calls == [] + + +def test_runner_failure_stops_at_first_config(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + calls: list[list[str]] = [] + + def runner(command, **_kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 23, stdout="", stderr="invalid merge") + + with pytest.raises(gate.ComposeGateError, match="exit code 23"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=runner, + ) + assert len(calls) == 1 + + +def test_unexpected_service_cannot_bypass_offline_policy_gate(tmp_path: Path) -> None: + fixture = _make_fixture(tmp_path) + + def mutate(services, _offline, _profiles, _command): + services["unreviewed-sidecar"] = {"image": "example.invalid/sidecar:latest"} + + with pytest.raises(gate.ComposeGateError, match="unexpected production services"): + gate.verify_production_compose( + env_file=fixture.env_file, + repo_root=fixture.repo, + runner=FakeComposeRunner(fixture, mutate), + ) diff --git a/yuqei-infra/tests/test_production_compose_preflight.py b/yuqei-infra/tests/test_production_compose_preflight.py new file mode 100644 index 00000000..7ad66890 --- /dev/null +++ b/yuqei-infra/tests/test_production_compose_preflight.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import copy +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +COMPOSE_DIR = REPO_ROOT / "yuqei-infra" / "compose" +BASE_COMPOSE = COMPOSE_DIR / "docker-compose.yml" +PRODUCTION_COMPOSE = COMPOSE_DIR / "docker-compose.production.yml" +OFFLINE_COMPOSE = COMPOSE_DIR / "docker-compose.production.offline.yml" +ENV_EXAMPLE = COMPOSE_DIR / ".env.production.example" + +APPLICATION_SERVICES = { + "contract-api-migrate", + "contract-api", + "contract-review-worker", + "contract-playbook-governance-worker", + "ai-platform-api-migrate", + "ai-platform-prompt-assets-import", + "ai-platform-api", + "worker", + "contract-web", +} +PRODUCTION_IMAGE_SERVICES = APPLICATION_SERVICES | { + "postgres", + "postgres-init-databases", + "redis", + "minio", + "edge", + "contract-files-backup", + "minio-backup", +} +PROFILE_SETS = ( + frozenset(), + frozenset({"backend-hosted-web"}), + frozenset({"ops"}), + frozenset({"backend-hosted-web", "ops"}), +) +RESET_MARKER = "__compose_reset_marker__" + + +def _load_script(name: str, relative_path: str) -> ModuleType: + path = REPO_ROOT / relative_path + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +preflight = _load_script( + "yuqei_production_compose_preflight", + "yuqei-infra/deploy/preflight-production.py", +) + + +class _ComposeLoader(yaml.SafeLoader): + pass + + +def _construct_reset(_loader: _ComposeLoader, _node: yaml.Node) -> str: + return RESET_MARKER + + +_ComposeLoader.add_constructor("!reset", _construct_reset) + + +def _load_compose(path: Path) -> dict[str, Any]: + document = yaml.load(path.read_text(encoding="utf-8"), Loader=_ComposeLoader) + assert isinstance(document, dict) + return document + + +def _merge_mapping(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + merged = copy.deepcopy(base) + for key, value in override.items(): + if value == RESET_MARKER: + merged.pop(key, None) + elif isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _merge_mapping(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def _selected_services(model: dict[str, Any], profiles: frozenset[str]) -> dict[str, Any]: + selected: dict[str, Any] = {} + for name, service in model["services"].items(): + service_profiles = set(service.get("profiles", [])) + if not service_profiles or service_profiles & profiles: + selected[name] = service + return selected + + +def _production_model() -> dict[str, Any]: + return _merge_mapping(_load_compose(BASE_COMPOSE), _load_compose(PRODUCTION_COMPOSE)) + + +def test_production_merge_removes_all_inherited_application_builds() -> None: + base = _load_compose(BASE_COMPOSE) + assert { + name for name, service in base["services"].items() if "build" in service + } == APPLICATION_SERVICES + + production = _production_model() + for profiles in PROFILE_SETS: + selected = _selected_services(production, profiles) + assert all("build" not in service for service in selected.values()) + + for name in APPLICATION_SERVICES: + service = production["services"][name] + assert "build" not in service + assert service["image"] + assert service["pull_policy"] == "always" + + +def test_offline_merge_disables_pulls_for_every_production_profile() -> None: + offline_override = _load_compose(OFFLINE_COMPOSE) + assert set(offline_override["services"]) == PRODUCTION_IMAGE_SERVICES + + offline = _merge_mapping(_production_model(), offline_override) + assert set(offline["services"]) == PRODUCTION_IMAGE_SERVICES + for profiles in PROFILE_SETS: + selected = _selected_services(offline, profiles) + assert all(service["pull_policy"] == "never" for service in selected.values()) + assert all("build" not in service for service in selected.values()) + + +def test_contract_web_image_is_required_and_must_be_immutable() -> None: + values = preflight.parse_env_file(ENV_EXAMPLE) + assert "CONTRACT_WEB_IMAGE" in values + + values["CONTRACT_WEB_IMAGE"] = "" + checks = {check.key: check for check in preflight.validate_environment(values)} + assert checks["required_values"].status == "fail" + assert "CONTRACT_WEB_IMAGE" in checks["required_values"].message + + digest = "sha256:" + "d" * 64 + for key in ( + "CONTRACT_API_IMAGE", + "CONTRACT_WEB_IMAGE", + "AI_PLATFORM_API_IMAGE", + "AI_WORKER_IMAGE", + "POSTGRES_IMAGE", + "POSTGRES_CLIENT_IMAGE", + "REDIS_IMAGE", + "MINIO_IMAGE", + "MINIO_MC_IMAGE", + "NGINX_IMAGE", + "BACKUP_UTILITY_IMAGE", + ): + values[key] = f"registry.internal/{key.lower()}@{digest}" + + checks = {check.key: check for check in preflight.validate_environment(values)} + assert checks["immutable_images"].status == "pass" + + values["CONTRACT_WEB_IMAGE"] = "registry.internal/contract-web:latest" + checks = {check.key: check for check in preflight.validate_environment(values)} + assert checks["immutable_images"].status == "fail" + assert "CONTRACT_WEB_IMAGE" in checks["immutable_images"].message diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py index b2ce1583..3789c1a9 100644 --- a/yuqei-infra/tests/test_production_release_tools.py +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -58,6 +58,7 @@ def production_values() -> dict[str, str]: replacements = { "RELEASE_ID": "v2.0.0-rc.1", "CONTRACT_API_IMAGE": f"registry.internal/contract-api@sha256:{'1' * 64}", + "CONTRACT_WEB_IMAGE": f"registry.internal/contract-web@sha256:{'c' * 64}", "AI_PLATFORM_API_IMAGE": f"registry.internal/ai-platform-api@sha256:{'2' * 64}", "AI_PLATFORM_RELEASE_IMAGE_DIGEST": f"sha256:{'2' * 64}", "CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST": f"sha256:{'1' * 64}", diff --git a/yuqei-infra/tests/test_release_candidate_manifest_tools.py b/yuqei-infra/tests/test_release_candidate_manifest_tools.py index 14197139..b3dca94a 100644 --- a/yuqei-infra/tests/test_release_candidate_manifest_tools.py +++ b/yuqei-infra/tests/test_release_candidate_manifest_tools.py @@ -305,6 +305,13 @@ def test_worktree_classifier_has_explicit_release_ownership(path: str, category: def test_manifest_is_deterministic_and_summarizes_complete_committed_graph(tmp_path: Path) -> None: + assembler_hardening_test = ( + "yuqei-infra/tests/test_installable_release_assembler_hardening.py" + ) + assert assembler_hardening_test in manifest.RELEASE_CONTROL_PATHS + assert assembler_hardening_test in ownership.REQUIRED_WORKTREE_ANCHORS + assert assembler_hardening_test in ownership.RUNTIME_COMPONENTS["deployment"] + assert ownership.classify_path(assembler_hardening_test)[0] == "release_test_include" pinned_bases = { "yuqei-infra/docker/contract-api.Dockerfile": ( "ARG PYTHON_BASE_IMAGE=python:3.12-slim@sha256:44ff437bba879d4941b710a369a8f19266aea34b29002807f0c487fabc9eec9b", @@ -329,6 +336,18 @@ def test_manifest_is_deterministic_and_summarizes_complete_committed_graph(tmp_p assert from_line in lines assert sum(line.startswith("FROM ") for line in lines) == 1 + web_dockerfile = ( + REPO_ROOT / "yuqei-infra/docker/contract-web.Dockerfile" + ).read_text(encoding="utf-8") + assert "/usr/local/lib/node_modules/npm" in web_dockerfile + assert "USER node" in web_dockerfile.splitlines() + assert ( + 'CMD ["node", "/app/node_modules/next/dist/bin/next", "start", ' + '"/app/yuqei-contract-platform/apps/contract-web", "-p", "3000"]' + in web_dockerfile + ) + assert 'CMD ["npm"' not in web_dockerfile + dockerignore_rules = { line.strip() for line in (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines() @@ -411,6 +430,16 @@ def test_manifest_is_deterministic_and_summarizes_complete_committed_graph(tmp_p assert first["summaries"]["runtime_compatibility"]["minimum_glibc"] == "2.28" assert first["summaries"]["migrations"][0]["heads"] == ["contract-head"] assert first["summaries"]["migrations"][1]["heads"] == ["ai-head"] + recorded_paths = {item["path"] for item in first["files"]["items"]} + assert set(manifest.RELEASE_CONTROL_PATHS) <= recorded_paths + configuration_paths = {item["path"] for item in first["summaries"]["configuration"]} + assert set(manifest.RELEASE_CONTROL_PATHS) <= configuration_paths + release_component = next( + item + for item in first["summaries"]["runtime_build_graph"] + if item["component"] == "deployment_and_release_controls" + ) + assert release_component["file_count"] >= len(manifest.RELEASE_CONTROL_PATHS) package_record = next(item for item in first["files"]["items"] if item["path"] == "package.json") package_bytes = (repo / "package.json").read_bytes() assert package_record["size"] == len(package_bytes) @@ -431,6 +460,11 @@ def test_manifest_refuses_ambiguous_dirty_missing_and_secret_candidates(tmp_path without_package = [item for item in entries if item.path != "package.json"] with pytest.raises(manifest.ManifestError, match="missing_paths"): manifest.validate_tree_paths(without_package) + without_release_control = [ + item for item in entries if item.path != manifest.RELEASE_CONTROL_PATHS[0] + ] + with pytest.raises(manifest.ManifestError, match="missing_paths"): + manifest.validate_tree_paths(without_release_control) _write(repo, "dirty.txt", "not committed\n") with pytest.raises(manifest.ManifestError, match="working tree is dirty"): @@ -446,6 +480,65 @@ def test_manifest_refuses_ambiguous_dirty_missing_and_secret_candidates(tmp_path manifest.build_manifest(repo, secret_commit, external_output) +def test_manifest_rejects_git_replace_refs_before_reading_candidate_objects( + tmp_path: Path, +) -> None: + repo, commit = _candidate_repo(tmp_path) + tree = _git(repo, "rev-parse", f"{commit}^{{tree}}") + replacement = _git( + repo, + "commit-tree", + tree, + "-p", + commit, + "-m", + "forbidden replacement commit", + ) + _git(repo, "replace", commit, replacement) + + with pytest.raises(manifest.ManifestError, match="refs/replace"): + manifest.build_manifest(repo, commit, tmp_path / "manifest.json") + + +def test_manifest_git_run_and_batch_reads_disable_replacements_and_ambient_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo, commit = _candidate_repo(tmp_path) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", str(tmp_path / "attacker-system-config")) + monkeypatch.setenv("GIT_REPLACE_REF_BASE", "refs/attacker-replace/") + observed_runs: list[tuple[list[str], dict[str, object]]] = [] + observed_popen: list[tuple[list[str], dict[str, object]]] = [] + actual_run = subprocess.run + actual_popen = subprocess.Popen + + def capture_run(arguments, **kwargs): + observed_runs.append((list(arguments), dict(kwargs))) + return actual_run(arguments, **kwargs) + + def capture_popen(arguments, **kwargs): + observed_popen.append((list(arguments), dict(kwargs))) + return actual_popen(arguments, **kwargs) + + monkeypatch.setattr(manifest.subprocess, "run", capture_run) + monkeypatch.setattr(manifest.subprocess, "Popen", capture_popen) + assert manifest._git(repo, "rev-parse", "--verify", f"{commit}^{{commit}}") == commit + entry = manifest.list_commit_tree(repo, commit)[0] + blobs = manifest.read_commit_blobs(repo, [entry]) + assert entry.path in blobs + + assert observed_runs + assert observed_popen + for arguments, kwargs in [*observed_runs, *observed_popen]: + assert arguments[1] == "--no-replace-objects" + child_env = kwargs["env"] + assert child_env["GIT_CONFIG_NOSYSTEM"] == "1" + assert child_env["GIT_NO_REPLACE_OBJECTS"] == "1" + assert child_env["GIT_OPTIONAL_LOCKS"] == "0" + assert child_env["GIT_TERMINAL_PROMPT"] == "0" + assert "GIT_CONFIG_SYSTEM" not in child_env + assert "GIT_REPLACE_REF_BASE" not in child_env + + def test_manifest_distinguishes_secret_domain_source_from_secret_material() -> None: safe_source_paths = ( "yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/secret_refs.py", diff --git a/yuqei-infra/tests/test_release_package_executor.py b/yuqei-infra/tests/test_release_package_executor.py new file mode 100644 index 00000000..c98f3073 --- /dev/null +++ b/yuqei-infra/tests/test_release_package_executor.py @@ -0,0 +1,1745 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_script(): + path = REPO_ROOT / "yuqei-infra/deploy/release-package.py" + spec = importlib.util.spec_from_file_location("release_package_executor", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +release_package = _load_script() + + +@dataclass +class PackageFixture: + root: Path + repo: Path + env_file: Path + package_file: Path + checksum_file: Path + source_file: Path + identity_file: Path + installable_file: Path + backup_file: Path | None + compatibility_file: Path | None + expected_sha256: str + trusted_receipt_sha256: str + import_items: tuple[dict[str, Any], ...] + + +_STRICT_BUILD_TEMPLATE: dict[str, Any] | None = None + + +def _load_linux_build_test_helpers(): + name = "release_package_linux_build_fixture_helpers" + existing = sys.modules.get(name) + if existing is not None: + return existing + path = REPO_ROOT / "yuqei-infra/tests/test_linux_release_build_tools.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _strict_build_template(tmp_path: Path) -> dict[str, Any]: + global _STRICT_BUILD_TEMPLATE + if _STRICT_BUILD_TEMPLATE is not None: + return _STRICT_BUILD_TEMPLATE + helpers = _load_linux_build_test_helpers() + bundle = helpers._bundle(tmp_path.parent / "release-package-strict-template-source") + receipt_sha256 = _sha(bundle["receipt_path"]) + installable = bundle["root"] / "installable-release-manifest.json" + assembler = release_package._load_installable_validator_module() + manifest = assembler.assemble_installable_release( + artifact_root=bundle["root"], + receipt_path=bundle["receipt_path"], + expected_receipt_sha256=receipt_sha256, + repo=bundle["repo"], + output=installable, + ) + _STRICT_BUILD_TEMPLATE = { + "bundle_root": bundle["root"], + "repo": bundle["repo"], + "manifest": manifest, + "receipt_sha256": receipt_sha256, + } + return _STRICT_BUILD_TEMPLATE + + +def _sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _with_hash(payload: dict[str, Any], field: str) -> dict[str, Any]: + result = dict(payload) + result[field] = release_package.canonical_sha256(result) + return result + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(release_package.canonical_json_bytes(payload) + b"\n") + + +def _image_record(name: str, digit: str) -> dict[str, str]: + digest = "sha256:" + digit * 64 + return { + "reference": f"registry.example.test/yuqei/{name}@{digest}", + "digest": digest, + } + + +def _owner_only_file(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8", newline="\n") + if os.name != "nt": + path.chmod(0o600) + return path + + +def _replace_env_value(path: Path, key: str, value: str) -> None: + lines = path.read_text(encoding="utf-8").splitlines() + prefix = key + "=" + assert sum(line.startswith(prefix) for line in lines) == 1 + path.write_text( + "\n".join(prefix + value if line.startswith(prefix) else line for line in lines) + + "\n", + encoding="utf-8", + newline="\n", + ) + + +def _public_policy_evidence(root: Path) -> dict[str, str]: + candidate = root / "public-policy" + candidate.mkdir(parents=True) + documents = [] + for policy_id, source_file in ( + ("user_terms", "user-terms.md"), + ("privacy_policy", "privacy-policy.md"), + ("service_agreement", "service-agreement.md"), + ("ai_use_notice", "ai-use-notice.md"), + ): + _owner_only_file(candidate / source_file, f"# {policy_id}\n") + documents.append({"policy_id": policy_id, "source_file": source_file}) + _owner_only_file( + candidate / "manifest.json", + json.dumps( + { + "schema_version": "yuqei-public-policy-candidate-v1", + "existing_user_reconsent": "required_on_next_login", + "documents": documents, + }, + ensure_ascii=False, + ), + ) + approval = _owner_only_file(root / "policy-approval.json", '{"approval_id":"test"}\n') + keyring = _owner_only_file( + root / "policy-keyring.json", + '{"trust_basis":{"policy_activation_authorized":false}}\n', + ) + return { + "PUBLIC_POLICY_CANDIDATE_DIR_HOST": str(candidate.resolve()), + "PUBLIC_POLICY_LEGAL_APPROVAL_FILE_HOST": str(approval.resolve()), + "PUBLIC_POLICY_TRUSTED_KEYRING_FILE_HOST": str(keyring.resolve()), + } + + +def _write_strict_production_env( + path: Path, + *, + application_images: dict[str, dict[str, str]], + infrastructure_images: dict[str, dict[str, str]], +) -> None: + preflight = release_package._load_preflight_module() + values = preflight.parse_env_file( + REPO_ROOT / "yuqei-infra/compose/.env.production.example" + ) + evidence = path.parent / "production-evidence" + tls = evidence / "tls" + _owner_only_file(tls / "tls.crt", "test certificate\n") + _owner_only_file(tls / "tls.key", "test private key\n") + provider_key = _owner_only_file(evidence / "provider-key", "test-provider-key\n") + provider_receipt = _owner_only_file( + evidence / "provider-receipt.json", + '{"status":"accepted"}\n', + ) + provider_keys = _owner_only_file( + evidence / "provider-trusted-keys.json", + '{"keys":[]}\n', + ) + webhook = _owner_only_file( + evidence / "alert-webhook-url", + "https://alerts.acme.cn/yuqei\n", + ) + backup = evidence / "backups" + backup.mkdir(parents=True) + + values.update( + { + "COMPOSE_PROJECT_NAME": "yuqei-prod", + "RELEASE_ID": "release-2", + **{key: value["reference"] for key, value in application_images.items()}, + **{key: value["reference"] for key, value in infrastructure_images.items()}, + "AI_PLATFORM_RELEASE_IMAGE_DIGEST": application_images[ + "AI_PLATFORM_API_IMAGE" + ]["digest"], + "CONTRACT_API_PUBLIC_POLICY_RELEASE_DIGEST": application_images[ + "CONTRACT_API_IMAGE" + ]["digest"], + "BACKEND_SERVER_NAME": "api.contracts.acme.cn", + "BACKEND_PUBLIC_BASE_URL": "https://api.contracts.acme.cn", + "BACKEND_TLS_CERT_DIR": str(tls.resolve()), + "POSTGRES_USER": "yuqei_prod", + "POSTGRES_PASSWORD": "postgres-secret-that-is-long-and-unique-001", + "MINIO_ROOT_USER": "yuqei_prod_minio", + "MINIO_ROOT_PASSWORD": "minio-secret-that-is-long-and-unique-002", + "CONTRACT_API_WEB_PROXY_SHARED_SECRET": "proxy-secret-that-is-long-and-unique-003", + "CONTRACT_API_IDENTITY_RATE_LIMIT_SECRET": "identity-secret-that-is-long-and-unique-004", + "CONTRACT_API_ANTI_ABUSE_HASH_SECRET": "contract-anti-abuse-secret-that-is-long-004a", + "CONTRACT_WEB_SESSION_SECRET": "session-secret-that-is-long-and-unique-005", + "AI_PLATFORM_SERVICE_SHARED_SECRET": "ai-service-secret-that-is-long-and-unique-006", + "AI_PLATFORM_ANTI_ABUSE_HASH_SECRET": "ai-anti-abuse-secret-that-is-long-unique-007", + "KMS_GATEWAY_IMAGE": "registry.internal/kms-gateway@sha256:" + "b" * 64, + "KMS_GATEWAY_ALLOWED_KEY_IDS": "calendar-envelope-key-v1", + "KMS_GATEWAY_PRIMARY_KEY_ID": "calendar-envelope-key-v1", + "KMS_GATEWAY_ALLOWED_SIGNING_KEY_IDS": "reliable-evidence-signing-key-v1", + "KMS_GATEWAY_SIGNING_KEY_ID": "reliable-evidence-signing-key-v1", + "KMS_GATEWAY_SIGNING_KEY_VERSION": "version-2026-08-25", + "ALIYUN_KMS_ENDPOINT": "kst-production.cryptoservice.kms.aliyuncs.com", + "AI_PLATFORM_GENERATIVE_AI_FILING_NUMBER": "内网信算备-TEST-001", + "AI_PLATFORM_GENERATIVE_AI_SERVICE_REGISTRATION_NUMBER": "内网信生测评-TEST-001", + "AI_PLATFORM_GENERATIVE_AI_SERVICE_PROVIDER_CODE": "yuqei-test-provider-code", + "AI_PROVIDER_API_KEY_FILE": str(provider_key.resolve()), + "AI_PLATFORM_PROVIDER_ACCEPTANCE_RECEIPT_FILE_HOST": str( + provider_receipt.resolve() + ), + "AI_PLATFORM_PROVIDER_ACCEPTANCE_TRUSTED_KEYS_FILE_HOST": str( + provider_keys.resolve() + ), + "AI_PLATFORM_PROVIDER_ACCEPTANCE_EXPECTED_RECEIPT_SHA256": _sha( + provider_receipt + ), + "PRODUCTION_ALERT_WEBHOOK_URL_FILE": str(webhook.resolve()), + "BACKUP_HOST_DIR": str(backup.resolve()), + "BACKUP_REQUIRE_SEPARATE_MOUNT": "false", + "CONTRACT_API_DATABASE_URL": ( + "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/" + "yuqei_contract" + ), + "AI_PLATFORM_DATABASE_URL": "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_ai", + "YUQEI_WORKER_DATABASE_URL": "postgresql+psycopg://yuqei_prod:encoded-password@postgres:5432/yuqei_ai", + } + ) + values.update(_public_policy_evidence(evidence)) + path.write_text( + "".join(f"{key}={value}\n" for key, value in sorted(values.items())), + encoding="utf-8", + newline="\n", + ) + if os.name != "nt": + path.chmod(0o600) + + +def _make_package(tmp_path: Path) -> PackageFixture: + template = _strict_build_template(tmp_path) + root = tmp_path / "package" + root.mkdir() + build_root = root / "build" + shutil.copytree(template["bundle_root"], build_root) + repo = tmp_path / "repo" + shutil.copytree(template["repo"], repo) + + source_file = build_root / "source/source-manifest.json" + identity_file = root / "image-identity.json" + installable_file = build_root / "installable-release-manifest.json" + checksum_file = root / "checksums.json" + package_file = root / release_package.PACKAGE_FILE + source = json.loads(source_file.read_text(encoding="utf-8")) + commit = source["candidate"]["commit"] + installable = json.loads(installable_file.read_text(encoding="utf-8")) + application_images = { + image["environment_key"]: { + "reference": image["registry_reference"], + "digest": image["manifest_digest"], + } + for image in installable["images"] + } + infrastructure_images = { + "POSTGRES_IMAGE": _image_record("postgres", "4"), + "POSTGRES_CLIENT_IMAGE": _image_record("postgres-client", "5"), + "REDIS_IMAGE": _image_record("redis", "6"), + "MINIO_IMAGE": _image_record("minio", "7"), + "MINIO_MC_IMAGE": _image_record("minio-mc", "9"), + "NGINX_IMAGE": _image_record("nginx", "e"), + "BACKUP_UTILITY_IMAGE": _image_record("backup-utility", "f"), + } + rollback_images = { + "CONTRACT_API_IMAGE": _image_record("contract-api", "a"), + "CONTRACT_WEB_IMAGE": _image_record("contract-web", "d"), + "AI_PLATFORM_API_IMAGE": _image_record("ai-platform-api", "b"), + "AI_WORKER_IMAGE": _image_record("ai-worker", "c"), + } + identity = _with_hash( + { + "schema_version": release_package.IMAGE_IDENTITY_SCHEMA, + "release_id": "release-2", + "source_commit": commit, + "source_manifest_sha256": _sha(source_file), + "application_images": application_images, + "infrastructure_images": infrastructure_images, + "rollback": { + "target_release_id": "release-1", + "evidence_status": "external_approval_missing", + "application_images": rollback_images, + }, + }, + "identity_payload_sha256", + ) + _write_json(identity_file, identity) + fixture = PackageFixture( + root=root, + repo=repo, + env_file=tmp_path / "production.env", + package_file=package_file, + checksum_file=checksum_file, + source_file=source_file, + identity_file=identity_file, + installable_file=installable_file, + backup_file=None, + compatibility_file=None, + expected_sha256="", + trusted_receipt_sha256=template["receipt_sha256"], + import_items=tuple(installable["docker_import_plan"]["images"]), + ) + _write_strict_production_env( + fixture.env_file, + application_images=application_images, + infrastructure_images=infrastructure_images, + ) + _refresh_outer_manifests(fixture) + return fixture + + +def _refresh_outer_manifests(fixture: PackageFixture) -> None: + excluded = {fixture.package_file.resolve(), fixture.checksum_file.resolve()} + artifact_files = tuple( + path + for path in fixture.root.rglob("*") + if path.is_file() and path.resolve() not in excluded + ) + entries = [ + { + "path": path.relative_to(fixture.root).as_posix(), + "size_bytes": path.stat().st_size, + "sha256": _sha(path), + } + for path in sorted( + artifact_files, + key=lambda item: item.relative_to(fixture.root).as_posix().encode("utf-8"), + ) + ] + checksums = _with_hash( + { + "schema_version": release_package.CHECKSUM_SCHEMA, + "release_id": "release-2", + "files": entries, + }, + "manifest_payload_sha256", + ) + _write_json(fixture.checksum_file, checksums) + + artifacts = { + "release_manifest": { + "path": fixture.source_file.relative_to(fixture.root).as_posix(), + "sha256": _sha(fixture.source_file), + }, + "checksum_manifest": { + "path": fixture.checksum_file.relative_to(fixture.root).as_posix(), + "sha256": _sha(fixture.checksum_file), + }, + "image_identity": { + "path": fixture.identity_file.relative_to(fixture.root).as_posix(), + "sha256": _sha(fixture.identity_file), + }, + "installable_manifest": { + "path": fixture.installable_file.relative_to(fixture.root).as_posix(), + "sha256": _sha(fixture.installable_file), + }, + } + if fixture.backup_file is not None: + artifacts["backup_proof"] = { + "path": fixture.backup_file.relative_to(fixture.root).as_posix(), + "sha256": _sha(fixture.backup_file), + } + if fixture.compatibility_file is not None: + artifacts["compatibility_proof"] = { + "path": fixture.compatibility_file.relative_to(fixture.root).as_posix(), + "sha256": _sha(fixture.compatibility_file), + } + package = _with_hash( + { + "schema_version": release_package.PACKAGE_SCHEMA, + "release_id": "release-2", + "compose_project_name": "yuqei-prod", + "deployment_scope": release_package.DEPLOYMENT_SCOPE, + "deployed_service_set": list(release_package.INSTALL_SERVICES), + "separately_deployed_artifacts": list( + release_package.SEPARATELY_DEPLOYED_IMAGE_KEYS + ), + "trusted_build_receipt_sha256": fixture.trusted_receipt_sha256, + "artifacts": artifacts, + }, + "package_payload_sha256", + ) + _write_json(fixture.package_file, package) + fixture.expected_sha256 = _sha(fixture.package_file) + + +def _rewrite_package(fixture: PackageFixture, mutate) -> None: + payload = json.loads(fixture.package_file.read_text(encoding="utf-8")) + payload.pop("package_payload_sha256") + mutate(payload) + _write_json(fixture.package_file, _with_hash(payload, "package_payload_sha256")) + fixture.expected_sha256 = _sha(fixture.package_file) + + +def _rewrite_installable_manifest(fixture: PackageFixture, mutate) -> None: + payload = json.loads(fixture.installable_file.read_text(encoding="utf-8")) + payload.pop("manifest_payload_sha256") + mutate(payload) + _write_json( + fixture.installable_file, + _with_hash(payload, "manifest_payload_sha256"), + ) + _refresh_outer_manifests(fixture) + + +def _attach_unverified_compatibility_material(fixture: PackageFixture) -> Path: + path = fixture.root / "rollback-compatibility.json" + payload = _with_hash( + { + "schema_version": release_package.COMPATIBILITY_PROOF_SCHEMA, + "from_release_id": "release-2", + "to_release_id": "release-1", + "status": "unverified", + "approval_id": None, + "database_downgrade_required": False, + "current_schema_is_backward_compatible": None, + "approved_application_digests": {}, + }, + "proof_payload_sha256", + ) + _write_json(path, payload) + fixture.compatibility_file = path + _refresh_outer_manifests(fixture) + return path + + +def _attach_unverified_backup_material(fixture: PackageFixture) -> Path: + path = fixture.root / "backup-proof.json" + payload = _with_hash( + { + "schema_version": release_package.BACKUP_PROOF_SCHEMA, + "release_id": "release-2", + "rollback_target_release_id": "release-1", + "status": "unverified", + "restore_tested": False, + "backup_id": None, + "backup_manifest_sha256": None, + }, + "proof_payload_sha256", + ) + _write_json(path, payload) + fixture.backup_file = path + _refresh_outer_manifests(fixture) + return path + + +def _forge_installable_field(payload: dict[str, Any], case: str) -> None: + if case == "top_level_extra": + payload["untrusted_extra"] = True + elif case == "target": + payload["target"]["platform"] = "linux/arm64" + elif case == "candidate_tag": + payload["candidate"]["tag"] = "v0.0.0-forged" + elif case == "candidate_tag_object": + payload["candidate"].pop("tag_object_oid") + elif case == "candidate_tree": + payload["candidate"]["tree"] = "0" * 40 + elif case == "candidate_checkout": + payload["candidate"]["checkout"]["head"] = "0" * 40 + elif case == "source_manifest": + payload["candidate"]["source_manifest_payload_sha256"] = "0" * 64 + elif case == "policy": + payload["policy"]["sha256"] = "0" * 64 + elif case == "build_receipt": + payload["build_receipt"]["payload_sha256"] = "0" * 64 + elif case == "security": + payload["security_gate"].pop("fail_closed") + elif case == "compose": + payload["compose_gate"]["case_count"] = 7 + elif case == "compose_execution_engine": + payload["compose_gate"]["execution_engine"] = "untrusted_external_script" + elif case == "release_controls": + payload["execution_contract"]["release_controls"].pop("common") + elif case == "files": + payload["files"].pop() + elif case == "sbom": + payload["images"][0]["sbom"]["sha256"] = "0" * 64 + elif case == "scan": + payload["images"][0]["scan"].pop("size_bytes") + elif case == "import_plan": + payload["docker_import_plan"]["images"][0]["command_argv"].append("--forged") + elif case == "claims": + payload["claims_not_made"] = [] + else: # pragma: no cover - guarded by the test parameter list + raise AssertionError(case) + + +class InspectionRunner: + def __init__(self) -> None: + self.calls: list[tuple[list[str], dict[str, Any]]] = [] + + def __call__(self, command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + self.calls.append((list(command), kwargs)) + if command[3] == "info": + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps( + { + "ID": "TEST-LOCAL-DAEMON-ID", + "Name": "yuqei-test-host", + "DockerRootDir": "/var/lib/docker", + "ServerVersion": "27.1.1", + } + ), + stderr="", + ) + assert command[3] == "compose" + compose_gate = release_package._load_compose_gate_module() + env_file = Path(command[command.index("--env-file") + 1]) + _target, env_values = compose_gate.parse_strict_env(env_file) + for env_key in set(compose_gate.APPLICATION_SERVICE_IMAGE_ENV.values()): + if env_key in kwargs["env"]: + env_values[env_key] = kwargs["env"][env_key] + profiles = { + command[index + 1] + for index, token in enumerate(command[:-1]) + if token == "--profile" + } + compose_files = [ + command[index + 1] + for index, token in enumerate(command[:-1]) + if token == "-f" + ] + offline = any( + Path(path).name == Path(release_package.OFFLINE_COMPOSE_RELATIVE).name + for path in compose_files + ) + services: dict[str, dict[str, Any]] = {} + for service_name in compose_gate.PRODUCTION_SERVICES: + required_profile = compose_gate.PROFILE_ONLY_SERVICES.get(service_name) + if required_profile is not None and required_profile not in profiles: + continue + service: dict[str, Any] = { + "image": f"registry.example.test/infra/{service_name}@sha256:{'e' * 64}" + } + image_env = compose_gate.APPLICATION_SERVICE_IMAGE_ENV.get(service_name) + if image_env is not None: + service["image"] = env_values[image_env] + service["pull_policy"] = "never" if offline else "always" + elif offline: + service["pull_policy"] = "never" + services[service_name] = service + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps({"name": env_values["COMPOSE_PROJECT_NAME"], "services": services}), + stderr="", + ) + + +class ApplyRunner: + def __init__(self, fixture: PackageFixture, compose_runner) -> None: + self.fixture = fixture + self.compose_runner = compose_runner + self.calls: list[list[str]] = [] + self.ids_by_tag = { + item["expected_repo_tag"]: item["expected_loaded_image_id"] + for item in fixture.import_items + } + + def __call__(self, command: list[str], **kwargs: Any): + self.calls.append(list(command)) + if command[3:6] == ["image", "load", "--input"]: + return subprocess.CompletedProcess(command, 0, stdout=b"loaded", stderr=b"") + if command[3:6] == ["image", "inspect", "--format"]: + return subprocess.CompletedProcess( + command, + 0, + stdout=(self.ids_by_tag[command[-1]] + "\n").encode("utf-8"), + stderr=b"", + ) + return self.compose_runner(command, **kwargs) + + +class FullApplyRunner: + def __init__( + self, + fixture: PackageFixture, + *, + before_command=None, + wrong_image_role: str | None = None, + compose_returncode: int = 0, + ) -> None: + self.before_command = before_command + self.wrong_image_role = wrong_image_role + self.compose_returncode = compose_returncode + self.calls: list[tuple[list[str], dict[str, Any]]] = [] + self.ids_by_tag = { + item["expected_repo_tag"]: ( + "sha256:" + "0" * 64 + if item["role"] == wrong_image_role + else item["expected_loaded_image_id"] + ) + for item in fixture.import_items + } + + def __call__(self, command: list[str], **kwargs: Any): + self.calls.append((list(command), kwargs)) + if self.before_command is not None: + self.before_command(command, kwargs) + if command[3:6] == ["image", "load", "--input"]: + return subprocess.CompletedProcess(command, 0, stdout=b"loaded", stderr=b"") + if command[3:6] == ["image", "inspect", "--format"]: + return subprocess.CompletedProcess( + command, + 0, + stdout=(self.ids_by_tag[command[-1]] + "\n").encode("utf-8"), + stderr=b"", + ) + assert command[3] == "compose" + return subprocess.CompletedProcess( + command, + self.compose_returncode, + stdout=b"compose-result", + stderr=b"compose-error" if self.compose_returncode else b"", + ) + + +def _post_apply_result(**kwargs: Any) -> dict[str, Any]: + plan = kwargs["plan"] + payload = { + "schema_version": release_package.POST_APPLY_SCHEMA, + "status": "verified", + "deployment_scope": release_package.DEPLOYMENT_SCOPE, + "operation": plan["operation"], + "target_release_id": plan["target_release_id"], + "deployed_service_set": plan["deployed_service_set"], + "daemon_identity_sha256": plan["docker_daemon_identity"]["identity_sha256"], + } + payload["post_check_payload_sha256"] = release_package.canonical_sha256(payload) + return payload + + +def _execute(fixture: PackageFixture, **overrides): + arguments = { + "package_dir": fixture.root, + "expected_package_sha256": fixture.expected_sha256, + "repo_root": fixture.repo, + "inspection_runner": InspectionRunner(), + } + if "runner" in overrides and overrides["runner"] is not None: + overrides["runner"] = ApplyRunner(fixture, overrides["runner"]) + overrides.setdefault("post_apply_checker", _post_apply_result) + arguments.update(overrides) + return release_package.execute(**arguments) + + +def test_default_verify_fully_validates_without_writes_or_docker(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + + def forbidden_runner(*_args, **_kwargs): + raise AssertionError("verify must not invoke Docker") + + outcome = _execute(fixture, state_dir=state, runner=forbidden_runner) + + assert outcome.exit_code == 0 + assert outcome.payload["mode"] == "verify" + assert outcome.payload["status"] == "structurally_verified_not_apply_eligible" + assert outcome.payload["package_structure_valid"] is True + assert outcome.payload["apply_eligible"] is False + assert outcome.payload["real_apply_feature_gate"] == { + "status": "blocked", + "blockers": list(release_package.REAL_APPLY_BLOCKERS), + } + assert outcome.payload["backup_compatibility_evidence_status"] == ( + "optional_self_hashed_material_untrusted_external_signatures_missing_blocked" + ) + assert outcome.payload["backup_evidence_status"] == "missing" + assert outcome.payload["compatibility_evidence_status"] == "missing" + assert outcome.payload["deployment_scope"] == "backend_only" + assert outcome.payload["deployed_service_set"] == [] + assert outcome.payload["managed_backend_service_set"] == list( + release_package.INSTALL_SERVICES + ) + assert outcome.payload["release_completion_claimed"] is False + assert len(outcome.payload["identity_bound_image_keys"]) == 11 + assert outcome.payload["separately_deployed_artifacts"][0]["image_key"] == ( + "CONTRACT_WEB_IMAGE" + ) + assert outcome.payload["separately_deployed_artifacts"][0][ + "applied_by_this_operation" + ] is False + assert outcome.payload["writes_performed"] is False + assert not state.exists() + + +def test_optional_self_hashed_release_evidence_remains_explicitly_unverified( + tmp_path: Path, +) -> None: + fixture = _make_package(tmp_path) + _attach_unverified_backup_material(fixture) + _attach_unverified_compatibility_material(fixture) + + outcome = _execute(fixture) + + assert outcome.payload["apply_eligible"] is False + assert outcome.payload["backup_evidence_status"] == "unverified_self_hashed" + assert outcome.payload["compatibility_evidence_status"] == "unverified_self_hashed" + + +@pytest.mark.parametrize("kind", ("backup", "compatibility")) +def test_self_hashed_material_cannot_claim_external_verification_or_approval( + tmp_path: Path, + kind: str, +) -> None: + fixture = _make_package(tmp_path) + if kind == "backup": + path = _attach_unverified_backup_material(fixture) + payload = json.loads(path.read_text(encoding="utf-8")) + payload.pop("proof_payload_sha256") + payload.update({"status": "verified", "restore_tested": True}) + else: + path = _attach_unverified_compatibility_material(fixture) + payload = json.loads(path.read_text(encoding="utf-8")) + payload.pop("proof_payload_sha256") + payload.update({"status": "approved", "approval_id": "self-asserted"}) + _write_json(path, _with_hash(payload, "proof_payload_sha256")) + _refresh_outer_manifests(fixture) + + with pytest.raises( + release_package.ReleasePackageError, + match="explicitly unverified", + ): + _execute(fixture) + + +def test_verify_dynamic_imports_do_not_create_bytecode_in_candidate_tree( + tmp_path: Path, +) -> None: + fixture_root = tmp_path / "fixture" + fixture_root.mkdir() + fixture = _make_package(fixture_root) + copied_infra = tmp_path / "copied-infra" + copied_deploy = copied_infra / "deploy" + copied_scripts = copied_infra / "scripts" + copied_deploy.mkdir(parents=True) + copied_scripts.mkdir(parents=True) + for name in ( + "release-package.py", + "preflight-production.py", + "verify-production-compose.py", + ): + shutil.copy2(REPO_ROOT / "yuqei-infra/deploy" / name, copied_deploy / name) + for name in ("build-installable-release.py", "linux_release_common.py"): + shutil.copy2(REPO_ROOT / "yuqei-infra/scripts" / name, copied_scripts / name) + + module_name = "copied_release_package_no_bytecode" + imported_names = ( + module_name, + "yuqei_release_package_installable_validator", + "yuqei_release_package_preflight", + "yuqei_release_package_compose_gate", + "linux_release_common", + ) + saved_modules = {name: sys.modules.get(name) for name in imported_names} + saved_sys_path = list(sys.path) + previous = sys.dont_write_bytecode + try: + for name in imported_names: + sys.modules.pop(name, None) + path = copied_deploy / "release-package.py" + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None + copied_module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = copied_module + sys.dont_write_bytecode = True + spec.loader.exec_module(copied_module) + sys.dont_write_bytecode = previous + + outcome = copied_module.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + repo_root=fixture.repo, + ) + assert outcome.payload["mode"] == "verify" + copied_module._load_preflight_module() + copied_module._load_compose_gate_module() + assert list(copied_infra.rglob("__pycache__")) == [] + assert list(copied_infra.rglob("*.pyc")) == [] + finally: + sys.dont_write_bytecode = previous + sys.path[:] = saved_sys_path + for name in imported_names: + sys.modules.pop(name, None) + if saved_modules[name] is not None: + sys.modules[name] = saved_modules[name] + + +def test_install_without_apply_is_an_offline_non_writing_plan(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + + outcome = _execute( + fixture, + operation="install", + env_file=fixture.env_file, + state_dir=state, + ) + + command = outcome.payload["command_argv"] + assert outcome.payload["mode"] == "plan" + assert outcome.payload["deployment_scope"] == "backend_only" + assert outcome.payload["deployed_service_set"] == list(release_package.INSTALL_SERVICES) + assert outcome.payload["release_completion_claimed"] is False + assert len(outcome.payload["identity_bound_image_keys"]) == 11 + assert "CONTRACT_WEB_IMAGE" not in outcome.payload["environment_overrides"] + assert outcome.payload["separately_deployed_artifacts"][0]["image_key"] == ( + "CONTRACT_WEB_IMAGE" + ) + assert outcome.payload["separately_deployed_artifacts"][0][ + "applied_by_this_operation" + ] is False + assert command[0:4] == [ + release_package.DOCKER_CLI, + "--host", + release_package.LOCAL_DOCKER_HOST, + "compose", + ] + assert command[command.index("--pull") + 1] == "never" + assert "--no-build" in command + assert outcome.payload["env_file_sha256"] == _sha(fixture.env_file) + assert outcome.payload["production_preflight"]["status"] == "passed" + assert len(outcome.payload["compose_merge_gate"]["cases"]) == 8 + assert len(outcome.payload["offline_execution_gate"]["cases"]) == 4 + assert outcome.payload["docker_daemon_identity"]["docker_host"] == ( + release_package.LOCAL_DOCKER_HOST + ) + compose_files = [command[index + 1] for index, token in enumerate(command) if token == "-f"] + assert compose_files == [ + str((fixture.repo / release_package.BASE_COMPOSE_RELATIVE).resolve()), + str((fixture.repo / release_package.PRODUCTION_COMPOSE_RELATIVE).resolve()), + str((fixture.repo / release_package.OFFLINE_COMPOSE_RELATIVE).resolve()), + ] + assert "edge" not in command + assert all(token not in command for token in ("down", "prune", "downgrade", "-v")) + assert not state.exists() + + +@pytest.mark.parametrize("confirmation", ("", "I_AUTHORIZE_THIS_EXACT_RELEASE_INSTALL ", "wrong")) +def test_apply_without_exact_authorization_fails_before_any_write( + tmp_path: Path, + confirmation: str, +) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + calls: list[object] = [] + + with pytest.raises(release_package.ReleasePackageError, match="confirmation"): + _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=confirmation, + idempotency_key="install-001", + state_dir=state, + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + assert calls == [] + assert not state.exists() + + +def test_apply_requires_safe_idempotency_key_before_any_write(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + + with pytest.raises(release_package.ReleasePackageError, match="idempotency"): + _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="../escape", + state_dir=state, + ) + + assert not state.exists() + + +def test_install_apply_uses_argv_not_shell_and_writes_canonical_receipt(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + injected_env = tmp_path / "production;echo-owned.env" + injected_env.write_bytes(fixture.env_file.read_bytes()) + fixture.env_file = injected_env + state = tmp_path / "state" + calls: list[tuple[list[str], dict[str, Any]]] = [] + + def runner(command, **kwargs): + calls.append((command, kwargs)) + return subprocess.CompletedProcess(command, 0, stdout=b"installed", stderr=b"") + + outcome = _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="install-001", + state_dir=state, + runner=runner, + now=datetime(2026, 9, 20, 4, 0, tzinfo=UTC), + ) + + assert outcome.payload["status"] == "backend_verified" + assert outcome.payload["deployment_scope"] == "backend_only" + assert outcome.payload["deployed_service_set"] == list(release_package.INSTALL_SERVICES) + assert outcome.payload["release_completion_claimed"] is False + assert len(calls) == 1 + command, kwargs = calls[0] + assert kwargs["shell"] is False + snapshotted_env = Path(command[command.index("--env-file") + 1]) + assert snapshotted_env != injected_env.resolve() + assert snapshotted_env.name == "production.env" + assert snapshotted_env.read_bytes() == injected_env.read_bytes() + assert ";echo-owned" not in str(snapshotted_env) + assert command[command.index("--pull") + 1] == "never" + assert "--no-build" in command + expected_contract_api = next( + item["expected_loaded_image_id"] + for item in fixture.import_items + if item["environment_key"] == "CONTRACT_API_IMAGE" + ) + assert kwargs["env"]["CONTRACT_API_IMAGE"] == expected_contract_api + assert kwargs["env"] == { + "HOME": "/var/empty", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + **outcome.payload["environment_overrides"], + } + receipts = list((state / "receipts").iterdir()) + assert len(receipts) == 1 + stored = json.loads(receipts[0].read_text(encoding="utf-8")) + assert receipts[0].read_bytes() == release_package.canonical_json_bytes(stored) + b"\n" + release_package._validate_receipt_payload(stored, label="test receipt") + assert list((state / "pending").iterdir()) == [] + assert not (tmp_path / "owned").exists() + + +def test_apply_is_idempotent_and_conflicting_reuse_fails_closed(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + calls: list[list[str]] = [] + + def runner(command, **_kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, stdout=b"ok", stderr=b"") + + arguments = { + "operation": "install", + "env_file": fixture.env_file, + "apply": True, + "confirmation": release_package.CONFIRMATIONS["install"], + "idempotency_key": "stable-operation-001", + "state_dir": state, + "runner": runner, + "now": datetime(2026, 9, 20, 4, 0, tzinfo=UTC), + } + first = _execute(fixture, **arguments) + second = _execute(fixture, **arguments) + + assert first.payload == second.payload + assert second.replayed is True + assert len(calls) == 1 + + with pytest.raises(release_package.ReleasePackageError, match="different release operation"): + _execute( + fixture, + operation="forward", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["forward"], + idempotency_key="stable-operation-001", + state_dir=state, + runner=runner, + ) + assert len(calls) == 1 + + +def test_exclusive_lock_blocks_concurrent_apply(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + state.mkdir() + if os.name != "nt": + state.chmod(0o700) + (state / ".release-package.lock").write_text("held\n", encoding="utf-8") + + with pytest.raises(release_package.ReleasePackageError, match="exclusive lock"): + _execute( + fixture, + operation="forward", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["forward"], + idempotency_key="forward-001", + state_dir=state, + runner=lambda *_args, **_kwargs: None, + ) + + +def test_rollback_plan_is_scoped_but_apply_fails_without_approved_import_plan( + tmp_path: Path, +) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + outcome = _execute( + fixture, + operation="rollback", + env_file=fixture.env_file, + state_dir=state, + ) + + command = outcome.payload["command_argv"] + assert outcome.payload["target_release_id"] == "release-1" + assert "--no-deps" in command + assert command[-len(release_package.RUNTIME_SERVICES) :] == list(release_package.RUNTIME_SERVICES) + assert all(service not in command for service in ("postgres", "redis", "minio", "edge")) + assert outcome.payload["database_downgrade_allowed"] is False + assert outcome.payload["edge_or_dns_change_allowed"] is False + assert set(outcome.payload["environment_overrides"]) == { + *release_package.BACKEND_APPLICATION_IMAGE_KEYS, + "AI_PLATFORM_RELEASE_IMAGE_DIGEST", + } + assert outcome.payload["environment_overrides"]["CONTRACT_API_IMAGE"].endswith( + "@sha256:" + "a" * 64 + ) + assert "CONTRACT_WEB_IMAGE" not in outcome.payload["environment_overrides"] + assert outcome.payload["separately_deployed_artifacts"][0]["reference"].endswith( + "@sha256:" + "d" * 64 + ) + assert outcome.payload["deployed_service_set"] == list(release_package.RUNTIME_SERVICES) + assert "contract-web" not in command + assert "edge" not in command + assert outcome.payload["real_apply_feature_gate"]["blockers"] == [ + *release_package.REAL_APPLY_BLOCKERS, + "rollback_docker_import_plan_not_available", + ] + + calls: list[object] = [] + with pytest.raises(release_package.ReleasePackageError, match="Rollback apply is fail-closed"): + _execute( + fixture, + operation="rollback", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["rollback"], + idempotency_key="rollback-001", + state_dir=state, + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + assert calls == [] + assert not state.exists() + + +def test_failed_docker_apply_leaves_canonical_reconciliation_journal(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + + def runner(command, **_kwargs): + return subprocess.CompletedProcess(command, 17, stdout=b"partial", stderr=b"private detail") + + outcome = _execute( + fixture, + operation="forward", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["forward"], + idempotency_key="forward-failed-001", + state_dir=state, + runner=runner, + now=datetime(2026, 9, 20, 4, 10, tzinfo=UTC), + ) + + assert outcome.exit_code == 1 + assert outcome.payload["status"] == "reconcile_required" + serialized = json.dumps(outcome.payload) + assert "private detail" not in serialized + assert "result" not in outcome.payload + assert list((state / "receipts").iterdir()) == [] + pending = list((state / "pending").iterdir()) + assert len(pending) == 1 + stored = json.loads(pending[0].read_text(encoding="utf-8")) + assert pending[0].read_bytes() == release_package.canonical_json_bytes(stored) + b"\n" + assert stored == outcome.payload + assert release_package._read_pending_journal(pending[0]) == stored + + replay = _execute( + fixture, + operation="forward", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["forward"], + idempotency_key="forward-failed-001", + state_dir=state, + runner=runner, + now=datetime(2026, 9, 20, 4, 10, tzinfo=UTC), + ) + assert replay.replayed is True + assert replay.exit_code == 1 + assert replay.payload == stored + + +def test_real_apply_entrypoint_stays_closed_while_external_release_proofs_are_missing( + tmp_path: Path, +) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + inspection = InspectionRunner() + + plan = release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="install", + env_file=fixture.env_file, + repo_root=fixture.repo, + inspection_runner=inspection, + ).payload + assert plan["real_apply_feature_gate"] == { + "status": "blocked", + "blockers": list(release_package.REAL_APPLY_BLOCKERS), + } + assert plan["apply_eligible"] is False + assert plan["backup_compatibility_evidence_status"] == ( + "optional_self_hashed_material_untrusted_external_signatures_missing_blocked" + ) + + blocked_inspection = InspectionRunner() + with pytest.raises(release_package.ReleasePackageError, match="Real apply is fail-closed"): + release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="real-entrypoint-001", + state_dir=state, + repo_root=fixture.repo, + inspection_runner=blocked_inspection, + ) + assert blocked_inspection.calls == [] + assert not state.exists() + + +def test_ambient_docker_and_compose_controls_cannot_redirect_apply( + tmp_path: Path, + monkeypatch, +) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + inspection = InspectionRunner() + runner = FullApplyRunner(fixture) + hostile = { + "PATH": str(tmp_path / "attacker-bin"), + "DOCKER_HOST": "tcp://attacker.example:2375", + "DOCKER_CONTEXT": "attacker-context", + "DOCKER_CONFIG": str(tmp_path / "attacker-config"), + "COMPOSE_FILE": str(tmp_path / "attacker-compose.yml"), + "COMPOSE_PATH_SEPARATOR": ";", + "COMPOSE_PROFILES": "attacker-profile", + "COMPOSE_PROJECT_NAME": "attacker-project", + } + for key, value in hostile.items(): + monkeypatch.setenv(key, value) + + outcome = release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="ambient-controls-001", + state_dir=state, + repo_root=fixture.repo, + runner=runner, + inspection_runner=inspection, + post_apply_checker=_post_apply_result, + now=datetime(2026, 9, 20, 4, 20, tzinfo=UTC), + ) + + assert outcome.exit_code == 0 + assert outcome.payload["docker_daemon_identity"]["docker_cli"] == ( + release_package.DOCKER_CLI + ) + assert outcome.payload["docker_daemon_identity"]["docker_host"] == ( + release_package.LOCAL_DOCKER_HOST + ) + all_calls = [*inspection.calls, *runner.calls] + assert all_calls + for command, kwargs in all_calls: + assert command[:3] == [ + release_package.DOCKER_CLI, + "--host", + release_package.LOCAL_DOCKER_HOST, + ] + assert kwargs["shell"] is False + assert not set(hostile).intersection(kwargs["env"]) + + +def test_preflight_failure_stops_before_state_or_apply_runner(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + calls: list[object] = [] + _replace_env_value( + fixture.env_file, + "POSTGRES_IMAGE", + "registry.example.test/yuqei/postgres:latest", + ) + + with pytest.raises(release_package.ReleasePackageError, match="Production preflight failed"): + _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="preflight-failure-001", + state_dir=state, + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + assert calls == [] + assert not state.exists() + + +@pytest.mark.parametrize("source_kind", ("env", "compose")) +def test_apply_snapshot_rejects_hard_linked_inputs_before_side_effects( + tmp_path: Path, + source_kind: str, +) -> None: + fixture = _make_package(tmp_path) + source = ( + fixture.env_file + if source_kind == "env" + else fixture.repo / release_package.BASE_COMPOSE_RELATIVE + ) + try: + os.link(source, tmp_path / f"{source_kind}-second-link") + except OSError as exc: + pytest.skip(f"hard links unavailable on this test filesystem: {exc}") + state = tmp_path / "state" + calls: list[object] = [] + + with pytest.raises(release_package.ReleasePackageError, match="single-link regular file"): + _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key=f"hardlink-{source_kind}-001", + state_dir=state, + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + assert calls == [] + assert not (state / "pending").exists() or list((state / "pending").iterdir()) == [] + assert not (state / "receipts").exists() or list((state / "receipts").iterdir()) == [] + + +def test_snapshot_rehash_detects_env_mutation_after_locked_plan(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + downstream_calls: list[object] = [] + + class MutatingInspectionRunner(InspectionRunner): + def __init__(self) -> None: + super().__init__() + self.compose_count = 0 + + def __call__(self, command: list[str], **kwargs: Any): + completed = super().__call__(command, **kwargs) + if command[3] == "compose": + self.compose_count += 1 + if self.compose_count == 24: + with fixture.env_file.open("a", encoding="utf-8", newline="\n") as handle: + handle.write("# mutated after locked evidence collection\n") + return completed + + with pytest.raises(release_package.ReleasePackageError, match="SHA-256 changed before snapshot"): + release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="env-toctou-001", + state_dir=state, + repo_root=fixture.repo, + runner=lambda *args, **kwargs: downstream_calls.append((args, kwargs)), + inspection_runner=MutatingInspectionRunner(), + post_apply_checker=_post_apply_result, + ) + assert downstream_calls == [] + assert not (state / "pending").exists() or list((state / "pending").iterdir()) == [] + + +def test_daemon_identity_change_before_apply_stops_before_side_effects(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + downstream_calls: list[object] = [] + + class ChangingDaemonInspectionRunner(InspectionRunner): + def __init__(self) -> None: + super().__init__() + self.info_count = 0 + + def __call__(self, command: list[str], **kwargs: Any): + completed = super().__call__(command, **kwargs) + if command[3] == "info": + self.info_count += 1 + if self.info_count == 3: + payload = json.loads(completed.stdout) + payload["ID"] = "DIFFERENT-LOCAL-DAEMON-ID" + completed = subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps(payload), + stderr="", + ) + return completed + + with pytest.raises(release_package.ReleasePackageError, match="daemon identity changed"): + release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="daemon-switch-001", + state_dir=state, + repo_root=fixture.repo, + runner=lambda *args, **kwargs: downstream_calls.append((args, kwargs)), + inspection_runner=ChangingDaemonInspectionRunner(), + post_apply_checker=_post_apply_result, + ) + assert downstream_calls == [] + assert not (state / "pending").exists() or list((state / "pending").iterdir()) == [] + + +def test_forged_minimal_completed_receipt_cannot_skip_execution(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + receipts = state / "receipts" + receipts.mkdir(parents=True) + if os.name != "nt": + state.chmod(0o700) + receipts.chmod(0o700) + key = "forged-receipt-001" + receipt_path = release_package._receipt_path(state, key) + forged = { + "schema_version": release_package.RECEIPT_SCHEMA, + "idempotency_key": key, + "plan_sha256": "0" * 64, + "status": "applied", + } + forged["receipt_payload_sha256"] = release_package.canonical_sha256(forged) + _write_json(receipt_path, forged) + calls: list[object] = [] + + with pytest.raises(release_package.ReleasePackageError, match="unexpected or missing fields"): + _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key=key, + state_dir=state, + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + assert calls == [] + + +def test_pending_journal_is_durable_before_first_docker_side_effect(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + key = "pending-before-side-effect-001" + observed: list[dict[str, Any]] = [] + + def before_command(command: list[str], _kwargs: dict[str, Any]) -> None: + if observed: + return + pending_path = release_package._pending_path(state, key) + assert command[3:6] == ["image", "load", "--input"] + assert pending_path.is_file() + payload = release_package._read_pending_journal(pending_path) + assert payload["phase"] == "pending_image_import_and_backend_apply" + assert not release_package._receipt_path(state, key).exists() + observed.append(payload) + + runner = FullApplyRunner(fixture, before_command=before_command) + outcome = release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key=key, + state_dir=state, + repo_root=fixture.repo, + runner=runner, + inspection_runner=InspectionRunner(), + post_apply_checker=_post_apply_result, + now=datetime(2026, 9, 20, 4, 30, tzinfo=UTC), + ) + + assert outcome.exit_code == 0 + assert len(observed) == 1 + assert not release_package._pending_path(state, key).exists() + assert runner.calls[0][0][:6] == [ + release_package.DOCKER_CLI, + "--host", + release_package.LOCAL_DOCKER_HOST, + "image", + "load", + "--input", + ] + + +def test_wrong_loaded_config_id_stops_before_compose_and_requires_reconciliation( + tmp_path: Path, +) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + runner = FullApplyRunner(fixture, wrong_image_role="contract-api") + outcome = release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="forward", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["forward"], + idempotency_key="wrong-config-id-001", + state_dir=state, + repo_root=fixture.repo, + runner=runner, + inspection_runner=InspectionRunner(), + post_apply_checker=_post_apply_result, + ) + + assert outcome.exit_code == 1 + assert outcome.payload["status"] == "reconcile_required" + assert not any(command[3] == "compose" for command, _kwargs in runner.calls) + assert len(list((state / "pending").iterdir())) == 1 + assert list((state / "receipts").iterdir()) == [] + + +def test_post_apply_check_failure_leaves_reconciliation_journal(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + runner = FullApplyRunner(fixture) + + outcome = release_package.execute( + package_dir=fixture.root, + expected_package_sha256=fixture.expected_sha256, + operation="forward", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["forward"], + idempotency_key="bad-post-check-001", + state_dir=state, + repo_root=fixture.repo, + runner=runner, + inspection_runner=InspectionRunner(), + post_apply_checker=lambda **_kwargs: {"status": "claimed-without-proof"}, + ) + + assert outcome.exit_code == 1 + assert outcome.payload["status"] == "reconcile_required" + assert any(command[3] == "compose" for command, _kwargs in runner.calls) + assert len(list((state / "pending").iterdir())) == 1 + assert list((state / "receipts").iterdir()) == [] + + +@pytest.mark.parametrize( + "case", + ( + "top_level_extra", + "target", + "candidate_tag", + "candidate_tag_object", + "candidate_tree", + "candidate_checkout", + "source_manifest", + "policy", + "build_receipt", + "security", + "compose", + "compose_execution_engine", + "release_controls", + "files", + "sbom", + "scan", + "import_plan", + "claims", + ), +) +def test_installable_manifest_is_strictly_reconstructed_before_acceptance( + tmp_path: Path, + case: str, +) -> None: + fixture = _make_package(tmp_path) + _rewrite_installable_manifest( + fixture, + lambda payload: _forge_installable_field(payload, case), + ) + + with pytest.raises( + release_package.ReleasePackageError, + match="Strict installable manifest validation failed", + ): + _execute(fixture) + + +def test_package_trust_anchor_cannot_be_replaced_by_a_reself_hashed_receipt_claim( + tmp_path: Path, +) -> None: + fixture = _make_package(tmp_path) + _rewrite_package( + fixture, + lambda payload: payload.update({"trusted_build_receipt_sha256": "0" * 64}), + ) + + with pytest.raises( + release_package.ReleasePackageError, + match="independently supplied raw SHA-256", + ): + _execute(fixture) + + +def test_path_traversal_in_package_manifest_fails_before_state_write(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + _rewrite_package( + fixture, + lambda payload: payload["artifacts"]["image_identity"].update( + {"path": "../outside-image-identity.json"} + ), + ) + + with pytest.raises(release_package.ReleasePackageError, match="path traversal"): + _execute(fixture, state_dir=state) + assert not state.exists() + + +def test_symbolic_linked_artifact_fails_closed(tmp_path: Path, monkeypatch) -> None: + fixture = _make_package(tmp_path) + original_is_symlink = Path.is_symlink + + def mark_identity_as_symlink(path: Path) -> bool: + if path == fixture.identity_file: + return True + return original_is_symlink(path) + + # This deterministically exercises the fail-closed branch on Windows hosts + # whose process token cannot create an actual symbolic link (WinError 1314). + monkeypatch.setattr(Path, "is_symlink", mark_identity_as_symlink) + + with pytest.raises(release_package.ReleasePackageError, match="symbolic link"): + _execute(fixture) + + +def test_missing_required_artifact_fails_closed(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + fixture.identity_file.unlink() + + with pytest.raises(release_package.ReleasePackageError, match="missing|escapes"): + _execute(fixture) + + +def test_wrong_artifact_checksum_fails_closed(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + fixture.identity_file.write_bytes(fixture.identity_file.read_bytes() + b" ") + state = tmp_path / "state" + calls: list[object] = [] + + with pytest.raises(release_package.ReleasePackageError, match="size mismatch|checksum mismatch"): + _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="invalid-package-must-not-write", + state_dir=state, + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + assert calls == [] + assert not state.exists() + + +@pytest.mark.parametrize("mode", ("missing", "hash_mismatch")) +def test_offline_compose_override_must_exist_and_match_release_manifest( + tmp_path: Path, + mode: str, +) -> None: + fixture = _make_package(tmp_path) + offline = fixture.repo / release_package.OFFLINE_COMPOSE_RELATIVE + if mode == "missing": + offline.unlink() + else: + offline.write_text("services: {}\n", encoding="utf-8", newline="\n") + + with pytest.raises(release_package.ReleasePackageError, match="offline|Compose"): + _execute(fixture) + + +def test_image_reference_digest_mismatch_fails_even_when_outer_hashes_match(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + identity = json.loads(fixture.identity_file.read_text(encoding="utf-8")) + identity.pop("identity_payload_sha256") + identity["application_images"]["CONTRACT_API_IMAGE"]["digest"] = "sha256:" + "9" * 64 + _write_json( + fixture.identity_file, + _with_hash(identity, "identity_payload_sha256"), + ) + _refresh_outer_manifests(fixture) + + with pytest.raises(release_package.ReleasePackageError, match="reference and digest"): + _execute(fixture) + + +def test_image_identity_requires_all_eleven_production_image_keys(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + identity = json.loads(fixture.identity_file.read_text(encoding="utf-8")) + identity.pop("identity_payload_sha256") + identity["infrastructure_images"].pop("NGINX_IMAGE") + _write_json(fixture.identity_file, _with_hash(identity, "identity_payload_sha256")) + _refresh_outer_manifests(fixture) + + with pytest.raises( + release_package.ReleasePackageError, + match="infrastructure_images must contain exactly", + ): + _execute(fixture) + + +def test_package_must_declare_backend_only_scope_and_separate_web(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + _rewrite_package( + fixture, + lambda payload: payload.update( + { + "deployment_scope": "full_release", + "separately_deployed_artifacts": [], + } + ), + ) + + with pytest.raises(release_package.ReleasePackageError, match="deployment_scope=backend_only"): + _execute(fixture) + + +def test_rollback_database_downgrade_proof_is_rejected(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + compatibility_file = _attach_unverified_compatibility_material(fixture) + proof = json.loads(compatibility_file.read_text(encoding="utf-8")) + proof.pop("proof_payload_sha256") + proof["database_downgrade_required"] = True + _write_json(compatibility_file, _with_hash(proof, "proof_payload_sha256")) + _refresh_outer_manifests(fixture) + + with pytest.raises(release_package.ReleasePackageError, match="Database downgrade"): + _execute(fixture) + + +def test_command_injection_project_name_is_rejected_before_docker_or_state(tmp_path: Path) -> None: + fixture = _make_package(tmp_path) + state = tmp_path / "state" + calls: list[object] = [] + _rewrite_package( + fixture, + lambda payload: payload.update({"compose_project_name": "prod;touch-owned"}), + ) + + with pytest.raises(release_package.ReleasePackageError, match="compose_project_name"): + _execute( + fixture, + operation="install", + env_file=fixture.env_file, + apply=True, + confirmation=release_package.CONFIRMATIONS["install"], + idempotency_key="install-injection", + state_dir=state, + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + assert calls == [] + assert not state.exists() + + +@pytest.mark.parametrize( + "command", + ( + ["docker", "compose", "down", "-v"], + ["docker", "compose", "up", "-d", "--pull", "never", "--no-build", "edge"], + ["docker", "compose", "up", "-d", "--pull", "always", "--no-build", "contract-api"], + ["docker", "compose", "up", "-d", "--pull", "always", "never", "--no-build", "contract-api"], + ["sh", "-c", "docker compose up"], + ), +) +def test_dangerous_or_non_offline_commands_are_rejected(command: list[str]) -> None: + with pytest.raises(release_package.ReleasePackageError): + release_package._assert_safe_compose_command(command, operation="install")