diff --git a/src/k1link/simulation/gaussian_pipeline_gateway.py b/src/k1link/simulation/gaussian_pipeline_gateway.py index d0737e0..c5c24c6 100644 --- a/src/k1link/simulation/gaussian_pipeline_gateway.py +++ b/src/k1link/simulation/gaussian_pipeline_gateway.py @@ -22,6 +22,8 @@ RESULT_SCHEMA: Final = "gaussian-pipeline.build-result/v1" CAPABILITIES_SCHEMA: Final = "gaussian-pipeline.capabilities/v1" SAFE_UPLOAD_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$") +SOURCE_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$") +IMAGE_DIGEST_PATTERN: Final = re.compile(r"^sha256:[a-f0-9]{64}$") DEFAULT_CHUNK_BYTES: Final = 8 * 1024 * 1024 MAX_JSON_RESPONSE_BYTES: Final = 32 * 1024 * 1024 MAX_RETRIES: Final = 3 @@ -103,8 +105,10 @@ class GaussianPipelineGateway: if ( document.get("schema_version") != CAPABILITIES_SCHEMA or document.get("service") != "ndc-gaussian-pipeline" + or document.get("api_version") != "gaussian-pipeline.api/v1" ): raise GaussianPipelineGatewayError("Gaussian provider capabilities do not match v1") + _validate_runtime_provenance(document) return document def upload_source( @@ -185,6 +189,7 @@ class GaussianPipelineGateway: result = self._json("GET", f"/v1/jobs/{quote(job_id, safe='')}/result") if result.get("schema_version") != RESULT_SCHEMA or result.get("job_id") != job_id: raise GaussianPipelineGatewayError("Gaussian result identity does not match") + _validate_runtime_provenance(result) return result def download_artifact( @@ -408,6 +413,21 @@ def _safe_id(value: str, label: str) -> None: raise GaussianPipelineGatewayError(f"Gaussian {label} is invalid") +def _validate_runtime_provenance(document: Mapping[str, object]) -> None: + runtime = document.get("runtime") + if not isinstance(runtime, dict): + raise GaussianPipelineGatewayError("Gaussian runtime provenance is unavailable") + source_revision = runtime.get("source_revision") + image_digest = runtime.get("image_digest") + if ( + not isinstance(source_revision, str) + or SOURCE_REVISION_PATTERN.fullmatch(source_revision) is None + or not isinstance(image_digest, str) + or IMAGE_DIGEST_PATTERN.fullmatch(image_digest) is None + ): + raise GaussianPipelineGatewayError("Gaussian runtime provenance is invalid") + + def _unavailable(message: str, error: httpx.HTTPError) -> GaussianPipelineGatewayError: if isinstance(error, httpx.TransportError): return GaussianPipelineUnavailableError(message) diff --git a/tests/test_gaussian_pipeline_gateway.py b/tests/test_gaussian_pipeline_gateway.py index b5a84d6..4c8b596 100644 --- a/tests/test_gaussian_pipeline_gateway.py +++ b/tests/test_gaussian_pipeline_gateway.py @@ -34,7 +34,12 @@ def test_gateway_uploads_with_tus_and_reads_provider_contract(tmp_path: Path) -> json={ "schema_version": "gaussian-pipeline.capabilities/v1", "service": "ndc-gaussian-pipeline", + "api_version": "gaussian-pipeline.api/v1", "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, + "runtime": { + "source_revision": "a" * 40, + "image_digest": f"sha256:{'b' * 64}", + }, }, ) if request.method == "POST" and request.url.path == "/v1/uploads": @@ -177,7 +182,14 @@ def test_gateway_submits_tracks_and_imports_digest_bound_artifact(tmp_path: Path if request.method == "GET" and request.url.path == f"/v1/jobs/{job_id}/result": return httpx.Response( 200, - json={"schema_version": "gaussian-pipeline.build-result/v1", "job_id": job_id}, + json={ + "schema_version": "gaussian-pipeline.build-result/v1", + "job_id": job_id, + "runtime": { + "source_revision": "a" * 40, + "image_digest": f"sha256:{'b' * 64}", + }, + }, ) if request.method == "GET" and request.url.path.endswith("/artifacts/preview.sog"): return httpx.Response(200, content=artifact) @@ -201,3 +213,25 @@ def test_gateway_submits_tracks_and_imports_digest_bound_artifact(tmp_path: Path tmp_path / "import" / "preview.sog", ) assert destination.read_bytes() == artifact + + +def test_gateway_rejects_capabilities_without_runtime_provenance(tmp_path: Path) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "schema_version": "gaussian-pipeline.capabilities/v1", + "service": "ndc-gaussian-pipeline", + "api_version": "gaussian-pipeline.api/v1", + }, + ) + + with ( + GaussianPipelineGateway( + "http://gaussian.test", + _token_file(tmp_path), + transport=httpx.MockTransport(handler), + ) as gateway, + pytest.raises(GaussianPipelineGatewayError, match="provenance is unavailable"), + ): + gateway.capabilities() diff --git a/tests/test_simulation_world_provider_api.py b/tests/test_simulation_world_provider_api.py index caf235e..53ad6c6 100644 --- a/tests/test_simulation_world_provider_api.py +++ b/tests/test_simulation_world_provider_api.py @@ -22,7 +22,12 @@ def _gateway(tmp_path: Path, status: int = 200) -> GaussianPipelineGateway: json={ "schema_version": "gaussian-pipeline.capabilities/v1", "service": "ndc-gaussian-pipeline", + "api_version": "gaussian-pipeline.api/v1", "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, + "runtime": { + "source_revision": "a" * 40, + "image_digest": f"sha256:{'b' * 64}", + }, }, )