from __future__ import annotations import hashlib import io import json import os import subprocess import sys import tarfile from datetime import UTC, datetime from pathlib import Path import pytest from scripts import build_mcp_hub_release_images as producer ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "build_mcp_hub_release_images.py" @pytest.mark.parametrize( "failure,category,code", [("exit", "nonzero_exit", 7), ("timeout", "timeout", None), ("os", "exec_error", None)], ) def test_fixed_stage_failure_reports_only_closed_phase_and_category( tmp_path, capsys, failure, category, code ): marker = "fixture-sensitive-output-must-not-escape" def runner(command, **kwargs): assert kwargs["stdout"] == subprocess.DEVNULL assert kwargs["stderr"] == subprocess.DEVNULL if failure == "timeout": raise subprocess.TimeoutExpired([marker], 1, output=marker, stderr=marker) if failure == "os": raise OSError(marker) return subprocess.CompletedProcess(command, code, stdout=marker, stderr=marker) env = { name: "fixture" for name in ( "GITEA_COMMIT_SHA", "APPOPS_SOURCE_TREE", "APPOPS_BUILD_ID", "APPOPS_BUILD_DATE", "SOURCE_DATE_EPOCH", ) } with pytest.raises(producer.BuildRejected) as error: producer._build_contextforge(tmp_path, env, runner=runner) message = str(error.value) assert "stage=contextforge_build" in message assert f"category={category}" in message assert (f"exitCode={code}" in message) if code is not None else "exitCode=" not in message assert marker not in message assert marker not in "".join(capsys.readouterr()) def test_unknown_stage_is_rejected_without_running_or_echoing_it(tmp_path): def runner(*args, **kwargs): pytest.fail("unknown stage must not execute") with pytest.raises(producer.BuildRejected, match="^fixed release stage identifier is invalid$"): producer._execute( ["fixture"], env={}, cwd=tmp_path, timeout=1, stage="fixture-sensitive-stage", runner=runner, ) @pytest.mark.parametrize("code", [True, "fixture-sensitive-exit", 2**40]) def test_invalid_exit_code_is_not_echoed(tmp_path, code): def runner(command, **kwargs): return subprocess.CompletedProcess(command, code) with pytest.raises(producer.BuildRejected, match="category=invalid_exit_code$"): producer._execute( ["fixture"], env={}, cwd=tmp_path, timeout=1, stage="compose_local_build", runner=runner ) def test_protected_metadata_uses_fixed_https_without_redirecting_credentials(monkeypatch): class Opener: def open(self, request, timeout): assert request.full_url == ( "https://gitea.example.com/api/v1/repos/jens/itworx_mcp_hub/actions/runs/123" ) assert request.get_header("Authorization") == "token fixture-token" assert timeout == 20 return io.BytesIO(b'{"id":123}') def build_opener(*handlers): assert handlers[0].proxies == {} assert ( handlers[1].redirect_request(None, None, 302, "redirect", {}, "https://other.test") is None ) return Opener() monkeypatch.setattr(producer.urllib.request, "build_opener", build_opener) assert producer._api_json("fixture-token", "/repos/jens/itworx_mcp_hub/actions/runs/123") == { "id": 123 } def _write_archive_fixture(path: Path, members: dict[str, bytes | None]) -> None: with tarfile.open(path, "w:", format=tarfile.USTAR_FORMAT) as archive: for name, payload in members.items(): info = tarfile.TarInfo( name + ("/" if payload is None and not name.endswith("/") else "") ) if payload is None: info.type = tarfile.DIRTYPE info.mode = 0o755 archive.addfile(info) else: info.size = len(payload) archive.addfile(info, io.BytesIO(payload)) def _docker29_oci_fixture( tmp_path: Path, ) -> tuple[Path, dict[str, bytes | None], dict[str, dict[str, str]], dict[str, str]]: tmp_path.mkdir(parents=True, exist_ok=True) revision = "a" * 40 metadata = { "revision": revision, "tree": "b" * 40, "buildId": revision[:12], "builtAt": "2026-10-03T12:34:56.000Z", "sourceDateEpoch": "1791027296", } layer_file = io.BytesIO() with tarfile.open(fileobj=layer_file, mode="w:", format=tarfile.USTAR_FORMAT) as layer_archive: content = b"synthetic Docker 29 image layer" item = tarfile.TarInfo("fixture.txt") item.size = len(content) layer_archive.addfile(item, io.BytesIO(content)) layer = layer_file.getvalue() layer_digest = "sha256:" + hashlib.sha256(layer).hexdigest() layer_name = "blobs/sha256/" + layer_digest.split(":", 1)[1] members: dict[str, bytes | None] = {"blobs": None, "blobs/sha256": None, layer_name: layer} images: dict[str, dict[str, str]] = {} docker_manifest: list[dict[str, object]] = [] index_descriptors: list[dict[str, object]] = [] for service in producer.ALL_SERVICES: if service in producer.LOCAL_BUILD_SERVICES: reference = f"{producer.IMAGE_REPOSITORIES[service]}:{revision}" elif service == "contextforge": reference = f"itworx-mcp-hub/contextforge-schema:{revision}" elif service == "connector-ingestion": reference = f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-run-123-attempt-1" else: reference = producer.EXTERNAL_IMAGES[service][1] labels: dict[str, str] = {} if service not in producer.EXTERNAL_IMAGES: for key, label in producer.OCI_LABELS.items(): labels[key] = ( "https://gitea.example.com/jens/itworx-mcp-hub" if key == "org.opencontainers.image.source" else metadata[label] ) config = { "architecture": "amd64", "os": "linux", "config": {"User": "10001:10001", "Labels": labels, "Cmd": [service]}, "rootfs": {"type": "layers", "diff_ids": [layer_digest]}, } config_bytes = json.dumps(config, sort_keys=True, separators=(",", ":")).encode() image_id = "sha256:" + hashlib.sha256(config_bytes).hexdigest() config_name = f"blobs/sha256/{image_id.split(':', 1)[1]}" members[config_name] = config_bytes images[service] = {"imageId": image_id, "archiveRef": reference, "imageRef": reference} docker_manifest.append( { "Config": config_name, "RepoTags": [reference], "Layers": [layer_name], "LayerSources": { layer_digest: { "mediaType": "application/vnd.oci.image.layer.v1.tar", "size": len(layer), "digest": layer_digest, } }, } ) image_manifest = { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": image_id, "size": len(config_bytes), }, "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar", "digest": layer_digest, "size": len(layer), } ], } image_manifest_bytes = json.dumps( image_manifest, sort_keys=True, separators=(",", ":") ).encode() manifest_digest = hashlib.sha256(image_manifest_bytes).hexdigest() members[f"blobs/sha256/{manifest_digest}"] = image_manifest_bytes index_descriptors.append( { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:" + manifest_digest, "size": len(image_manifest_bytes), "annotations": {"org.opencontainers.image.ref.name": reference}, "platform": {"os": "linux", "architecture": "amd64"}, } ) members["oci-layout"] = b'{"imageLayoutVersion":"1.0.0"}' members["index.json"] = json.dumps( { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": index_descriptors, }, sort_keys=True, separators=(",", ":"), ).encode() members["manifest.json"] = json.dumps(docker_manifest, separators=(",", ":")).encode() raw = tmp_path / "docker29-oci-save.tar" _write_archive_fixture(raw, members) return raw, members, images, metadata def test_missing_registry_credential_is_rejected_before_building(tmp_path: Path) -> None: env = { key: value for key, value in os.environ.items() if key not in {"GITEA_TOKEN", "HUB_RELEASE_REGISTRY_TOKEN"} } env.update( { "GITEA_TOKEN": "metadata-token-test-value", "GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/main", "GITHUB_REPOSITORY": "Jens/ITWorx_MCP_Hub", } ) result = subprocess.run( # noqa: S603 -- executes only this repository's fixed producer script. [ sys.executable, str(SCRIPT), "--revision", "a" * 40, "--run-id", "123", "--output", str(tmp_path / "candidate"), ], check=False, capture_output=True, text=True, env=env, ) assert result.returncode != 0 assert "HUB_RELEASE_REGISTRY_TOKEN" in result.stderr assert "metadata-token-test-value" not in result.stdout + result.stderr assert not (tmp_path / "candidate").exists() def test_run_binding_requires_the_one_successful_full_gate() -> None: revision = "a" * 40 run_id = 123 run = { "id": run_id, "path": producer.WORKFLOW_PATH, "event": "push", "head_branch": "main", "head_sha": revision, "repository": {"full_name": producer.DISPLAY_REPOSITORY}, "head_repository": {"full_name": producer.DISPLAY_REPOSITORY}, "pull_requests": [], "status": "in_progress", "run_attempt": 1, "created_at": "2026-10-03T12:34:56Z", } jobs = { "jobs": [ { "name": "full", "run_id": run_id, "run_attempt": 1, "head_sha": revision, "head_branch": "main", "status": "completed", "conclusion": "success", } ] } branch = {"commit": {"id": revision}} result = producer._validate_run(run, jobs, branch, revision=revision, run_id=run_id) assert result["workflowRunCreatedAt"] == "2026-10-03T12:34:56.000Z" assert result["runAttempt"] == 1 with pytest.raises(producer.BuildRejected): producer._validate_run( run, {"jobs": jobs["jobs"] * 2}, branch, revision=revision, run_id=run_id ) bad_job = {"jobs": [dict(jobs["jobs"][0], conclusion="failure")]} with pytest.raises(producer.BuildRejected): producer._validate_run(run, bad_job, branch, revision=revision, run_id=run_id) def test_cli_environment_rejects_docker_endpoint_overrides() -> None: revision = "b" * 40 env = { "GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/main", "GITHUB_REPOSITORY": producer.DISPLAY_REPOSITORY, "GITHUB_SHA": revision, "GITHUB_RUN_ID": "5", "DOCKER_HOST": "tcp://attacker.invalid:2375", } with pytest.raises(producer.BuildRejected): producer._validate_cli_environment(revision, 5, env) @pytest.mark.parametrize( ("buildx", "compose", "accepted"), [ ("github.com/docker/buildx v0.37.2", "v5.5.1", True), ("github.com/docker/buildx 0.37.2", "5.5.1", True), ("github.com/docker/buildx v0.37.20", "v5.5.1", False), ("github.com/docker/buildx v0.37.2", "v5.5.10", False), ], ) def test_job_daemon_requires_exact_buildx_and_compose_versions( buildx: str, compose: str, accepted: bool, ) -> None: def runner(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: if command[1:3] == ["info", "--format"]: output = 'daemon-identity-012345|29.8.2|overlay2|["name=rootless"]' elif command[1:] == ["buildx", "version"]: output = buildx elif command[1:] == ["compose", "version", "--short"]: output = compose else: pytest.fail("version admission invoked an unexpected command") return subprocess.CompletedProcess(command, 0, output, "") if accepted: assert producer._check_job_daemon({}, Path.cwd(), runner=runner) == "daemon-identity-012345" else: with pytest.raises(producer.BuildRejected): producer._check_job_daemon({}, Path.cwd(), runner=runner) def test_fixed_image_contract_is_17_services_with_12_compose_builds() -> None: revision = "c" * 40 services = { service: {"image": f"{repository}:{revision}"} for service, repository in producer.IMAGE_REPOSITORIES.items() } services["contextforge"] = {"image": f"itworx-mcp-hub/contextforge-schema:{revision}"} services["connector-ingestion"] = { "image": f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-pending@sha256:{'0' * 64}" } for service, (image, _archive) in producer.EXTERNAL_IMAGES.items(): services[service] = {"image": image} model = { "services": { service: { **definition, **({"build": {"context": "."}} if service in producer.LOCAL_BUILD_SERVICES else {}), } for service, definition in services.items() } } assert producer._validate_local_refs(model, revision) == { service: services[service]["image"] for service in producer.LOCAL_BUILD_SERVICES } model["services"]["connector-ingestion"]["build"] = {"context": "."} with pytest.raises(producer.BuildRejected): producer._validate_compose_model(model) wrong_ref = {"services": {name: dict(row) for name, row in model["services"].items()}} wrong_ref["services"]["control-api"]["image"] = "attacker.invalid/control:mutable" with pytest.raises(producer.BuildRejected): producer._validate_local_refs(wrong_ref, revision) def test_manifest_descriptor_is_strict_and_digest_bound() -> None: digest = "sha256:" + "d" * 64 descriptor = { "digest": digest, "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": 120, } assert producer._parse_manifest_descriptor(json.dumps(descriptor)) == digest with pytest.raises(producer.BuildRejected): producer._parse_manifest_descriptor(json.dumps({**descriptor, "extra": "x"})) with pytest.raises(producer.BuildRejected): producer._parse_manifest_descriptor(json.dumps({**descriptor, "digest": "sha256:bad"})) def test_docker29_oci_archive_normalizes_index_blobs_to_exact_ustar_importer_shape( tmp_path: Path, ) -> None: raw, _members, images, metadata = _docker29_oci_fixture(tmp_path) normalized = tmp_path / "normalized.tar" producer._normalize_docker_archive(raw, normalized, images, metadata=metadata) with tarfile.open(normalized, "r:") as archive: members = archive.getmembers() names = {member.name.rstrip("/") for member in members} image_ids = {row["imageId"].split(":", 1)[1] for row in images.values()} manifest_stream = archive.extractfile("manifest.json") assert manifest_stream is not None manifest_rows = json.loads(manifest_stream.read()) expected_names = {"manifest.json", *(f"{image_id}.json" for image_id in image_ids)} for row in manifest_rows: expected_names.update(layer[:-10] for layer in row["Layers"]) expected_names.update(row["Layers"]) assert names == expected_names assert len(manifest_rows) == len(producer.ALL_SERVICES) assert {row["RepoTags"][0] for row in manifest_rows} == { row["archiveRef"] for row in images.values() } assert all(member.type in {tarfile.REGTYPE, tarfile.DIRTYPE} for member in members) assert all(member.pax_headers == {} for member in members) with normalized.open("rb") as source: assert source.read(512)[257:263] == b"ustar\x00" if os.name != "nt": assert normalized.stat().st_mode & 0o777 == 0o600 def test_docker_oci_index_can_canonicalize_external_reference_aliases(tmp_path: Path) -> None: raw, members, images, metadata = _docker29_oci_fixture(tmp_path) index = json.loads(members["index.json"]) postgres_ref = images["postgres"]["archiveRef"] descriptor = next( item for item in index["manifests"] if item["annotations"]["org.opencontainers.image.ref.name"] == postgres_ref ) descriptor["annotations"]["org.opencontainers.image.ref.name"] = ( "docker.io/library/" + postgres_ref ) members["index.json"] = json.dumps(index, sort_keys=True, separators=(",", ":")).encode() _write_archive_fixture(raw, members) normalized = tmp_path / "canonical-alias-normalized.tar" producer._normalize_docker_archive(raw, normalized, images, metadata=metadata) assert normalized.is_file() def test_docker29_oci_normalizer_rejects_unreferenced_blobs_and_config_mismatch( tmp_path: Path, ) -> None: raw, members, images, metadata = _docker29_oci_fixture(tmp_path) orphan_name = "blobs/sha256/" + "f" * 64 members[orphan_name] = b"unreferenced" orphan_archive = tmp_path / "orphan.tar" _write_archive_fixture(orphan_archive, members) with pytest.raises(producer.BuildRejected): producer._normalize_docker_archive( orphan_archive, tmp_path / "orphan-normalized.tar", images, metadata=metadata, ) raw, _members, images, metadata = _docker29_oci_fixture(tmp_path / "mismatch") images["web"]["imageId"] = "sha256:" + "0" * 64 with pytest.raises(producer.BuildRejected): producer._normalize_docker_archive( raw, tmp_path / "mismatch-normalized.tar", images, metadata=metadata, ) def test_secret_scan_summary_keeps_finding_details_out_of_persisted_json() -> None: image_id = "sha256:" + "e" * 64 raw_secret = "super-secret-value-for-test" # noqa: S105 -- synthetic redaction fixture. raw = json.dumps({"Metadata": {"ImageID": image_id}, "Results": [{"Secrets": []}]}).encode() summary = producer._secret_summary(raw, image_id) encoded = json.dumps(summary) assert summary["FindingCount"] == 0 assert summary["SensitiveFindingDetailsPersisted"] is False assert raw_secret not in encoded finding = json.dumps( { "Metadata": {"ImageID": image_id}, "Results": [{"Secrets": [{"Match": raw_secret, "RuleID": "test"}]}], } ).encode() with pytest.raises(producer.BuildRejected): producer._secret_summary(finding, image_id) @pytest.mark.skipif( os.name != "posix", reason="bounded scanner stream requires POSIX process groups" ) def test_scanner_stdout_bound_does_not_cap_or_capture_cache_files(tmp_path: Path) -> None: def fake_scanner(command: list[str], **kwargs: object) -> subprocess.Popen[bytes]: assert "preexec_fn" not in kwargs assert kwargs.get("stdout") == subprocess.PIPE code = ( "from pathlib import Path; import os; " "Path('cache.bin').write_bytes(b'x' * (3 * 1024 * 1024)); " "os.write(1, b'{\"complete\":true}')" ) return subprocess.Popen( # noqa: S603 -- executes the fixed synthetic scanner fixture. [sys.executable, "-c", code], **kwargs, # type: ignore[arg-type] ) output = producer._run_scanner_capture( ["fake-trivy"], env=os.environ, cwd=tmp_path, timeout=10, popen=fake_scanner, maximum=64, ) assert output == b'{"complete":true}' assert (tmp_path / "cache.bin").stat().st_size == 3 * 1024 * 1024 @pytest.mark.skipif( os.name != "posix", reason="bounded scanner stream requires POSIX process groups" ) def test_scanner_stdout_over_limit_is_rejected_without_persisting_bytes(tmp_path: Path) -> None: def fake_scanner(command: list[str], **kwargs: object) -> subprocess.Popen[bytes]: code = "import os; os.write(1, b'x' * 65)" return subprocess.Popen( # noqa: S603 -- executes the fixed synthetic scanner fixture. [sys.executable, "-c", code], **kwargs, # type: ignore[arg-type] ) with pytest.raises(producer.BuildRejected): producer._run_scanner_capture( ["fake-trivy"], env=os.environ, cwd=tmp_path, timeout=10, popen=fake_scanner, maximum=64, ) assert list(tmp_path.iterdir()) == [] def test_ingestion_publication_tag_is_bound_to_authenticated_attempt() -> None: revision = "d" * 40 first = producer._ingestion_tag(revision, 456, 1) retry = producer._ingestion_tag(revision, 456, 2) assert first == f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-run-456-attempt-1" assert retry == f"{producer.IMAGE_PREFIX}/connector-ingestion:{revision}-run-456-attempt-2" assert first != retry with pytest.raises(producer.BuildRejected): producer._ingestion_tag("../" + revision, 456, 1) def test_registry_absence_probe_accepts_only_the_exact_missing_reference() -> None: reference = f"{producer.IMAGE_PREFIX}/connector-ingestion:{'e' * 40}-run-7-attempt-1" def missing(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: assert reference in command return subprocess.CompletedProcess(command, 1, "", f"ERROR: {reference}: not found") producer._registry_tag_absent(reference, {}, Path.cwd(), runner=missing) def denied(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(command, 1, "", "unauthorized: authentication required") with pytest.raises(producer.BuildRejected): producer._registry_tag_absent(reference, {}, Path.cwd(), runner=denied) def exists(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(command, 0, "{}", "") with pytest.raises(producer.BuildRejected): producer._registry_tag_absent(reference, {}, Path.cwd(), runner=exists) def test_vulnerability_report_requires_all_packages_and_rejects_fixable_critical() -> None: image_id = "sha256:" + "f" * 64 report = { "SchemaVersion": 2, "CreatedAt": datetime.now(UTC).isoformat(), "Metadata": {"ImageID": image_id, "ImageConfig": {"os": "linux", "architecture": "amd64"}}, "Trivy": {"Version": producer.TRIVY_VERSION}, "Results": [{"Packages": [{"Name": "pkg", "Version": "1.0"}], "Vulnerabilities": []}], } producer._validate_vulnerability(report, image_id) report["Results"][0]["Vulnerabilities"] = [{"Severity": "CRITICAL", "FixedVersion": "2.0"}] with pytest.raises(producer.BuildRejected): producer._validate_vulnerability(report, image_id) report["Results"][0]["Vulnerabilities"] = [] report["Results"][0]["Packages"] = [] with pytest.raises(producer.BuildRejected): producer._validate_vulnerability(report, image_id)